feat(gui): redesign kanholec GUI with modern interface and WiX v7 installer
golangci-lint / lint (push) Failing after 4s
golangci-lint / lint (push) Failing after 4s
- Complete GUI overhaul with Fyne framework * Modern dark theme with indigo/pink accent colors * 4-step setup wizard (Welcome → Server → Auth → Finish) * Dashboard with proxy list, real-time logs, and config viewer * Windows Service manager (install/start/stop/uninstall) * System tray integration with quick controls - WiX v7 MSI installer * Auto-registers as Windows Service (manual start) * Feature tree UI for optional components * Desktop shortcut and PATH options * Build script: packaging/windows/build-msi.ps1 - Build system updates * Added kanholec-windows-gui target (CGO required) * Added kanholec-windows-msi target * Separate main_gui.go entry point for GUI builds GUI binary: bin/kanholec-windows-amd64.exe (31.4 MB) MSI installer: bin/kanholec-0.69.0-amd64.msi (12.1 MB)
This commit is contained in:
@@ -47,13 +47,19 @@ kanholec-windows:
|
|||||||
kanholec-windows-arm64:
|
kanholec-windows-arm64:
|
||||||
env CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -tags "kanholec$(NOWEB_TAG)" -o bin/kanholec-windows-arm64.exe ./cmd/kanholec
|
env CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -tags "kanholec$(NOWEB_TAG)" -o bin/kanholec-windows-arm64.exe ./cmd/kanholec
|
||||||
|
|
||||||
kanholec-windows-msi: kanholec-windows
|
kanholec-windows-gui:
|
||||||
wixl -o bin/kanholec-$(KANHOLE_VERSION).msi packaging/windows/kanholec.wxs 2>/dev/null \
|
env CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -tags "kanholec,kanholec_gui$(NOWEB_TAG)" -o bin/kanholec-windows-amd64.exe ./cmd/kanholec
|
||||||
|| { echo "wixl failed. Try: apt install msitools"; \
|
|
||||||
echo "Or on Windows: candle kanholec.wxs -o kanholec.wixobj && light kanholec.wixobj -o kanholec.msi"; \
|
|
||||||
exit 1; }
|
|
||||||
|
|
||||||
.PHONY: kanholec-windows kanholec-windows-arm64 kanholec-windows-msi
|
kanholec-windows-gui-arm64:
|
||||||
|
env CGO_ENABLED=1 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -tags "kanholec,kanholec_gui$(NOWEB_TAG)" -o bin/kanholec-windows-arm64.exe ./cmd/kanholec
|
||||||
|
|
||||||
|
kanholec-windows-msi: kanholec-windows-gui
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging/windows/build-msi.ps1 -Arch amd64 -SkipBuild
|
||||||
|
|
||||||
|
kanholec-windows-msi-arm64: kanholec-windows-gui-arm64
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging/windows/build-msi.ps1 -Arch arm64 -SkipBuild
|
||||||
|
|
||||||
|
.PHONY: kanholec-windows kanholec-windows-arm64 kanholec-windows-msi kanholec-windows-msi-arm64 kanholec-windows-gui kanholec-windows-gui-arm64
|
||||||
|
|
||||||
test: gotest
|
test: gotest
|
||||||
|
|
||||||
|
|||||||
+482
-155
@@ -3,11 +3,17 @@
|
|||||||
package gui
|
package gui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/app"
|
"fyne.io/fyne/v2/app"
|
||||||
@@ -17,230 +23,551 @@ import (
|
|||||||
"fyne.io/fyne/v2/theme"
|
"fyne.io/fyne/v2/theme"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"kanhole/client"
|
||||||
"kanhole/pkg/config"
|
"kanhole/pkg/config"
|
||||||
|
"kanhole/pkg/config/source"
|
||||||
v1 "kanhole/pkg/config/v1"
|
v1 "kanhole/pkg/config/v1"
|
||||||
|
"kanhole/pkg/policy/security"
|
||||||
"kanhole/pkg/util/log"
|
"kanhole/pkg/util/log"
|
||||||
|
"kanhole/pkg/util/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateSetup AppState = iota
|
||||||
|
StateDashboard
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
fyneApp fyne.App
|
fyneApp fyne.App
|
||||||
window fyne.Window
|
window fyne.Window
|
||||||
|
wizard *Wizard
|
||||||
|
dashboard *Dashboard
|
||||||
|
tray *TrayManager
|
||||||
|
svcMgr *ServiceManager
|
||||||
|
|
||||||
serverURL *widget.Entry
|
mainContainer *fyne.Container
|
||||||
authToken *widget.Entry
|
state AppState
|
||||||
clientKey *widget.Entry
|
|
||||||
statusLabel *widget.Label
|
|
||||||
configView *widget.Entry
|
|
||||||
proxyList *widget.List
|
|
||||||
startBtn *widget.Button
|
|
||||||
stopBtn *widget.Button
|
|
||||||
connectBtn *widget.Button
|
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
configData string
|
configData string
|
||||||
|
clientService *client.Service
|
||||||
|
cancelFunc context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *App {
|
func New() *App {
|
||||||
a := &App{}
|
a := &App{}
|
||||||
a.fyneApp = app.New()
|
a.fyneApp = app.NewWithID("io.kanhole.kanholec")
|
||||||
a.window = a.fyneApp.NewWindow("kanholec GUI")
|
a.fyneApp.Settings().SetTheme(&kanholeTheme{})
|
||||||
a.window.Resize(fyne.NewSize(850, 620))
|
|
||||||
|
a.window = a.fyneApp.NewWindow("kanholec")
|
||||||
|
a.window.Resize(fyne.NewSize(900, 640))
|
||||||
a.window.SetMaster()
|
a.window.SetMaster()
|
||||||
a.setupUI()
|
|
||||||
|
a.svcMgr = NewServiceManager()
|
||||||
|
a.tray = NewTrayManager(a)
|
||||||
|
|
||||||
|
a.mainContainer = container.NewStack()
|
||||||
|
a.window.SetContent(a.mainContainer)
|
||||||
|
|
||||||
|
a.window.SetCloseIntercept(func() {
|
||||||
|
if a.tray.IsSupported() {
|
||||||
|
a.window.Hide()
|
||||||
|
} else {
|
||||||
|
a.Quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if a.hasSavedConfig() {
|
||||||
|
a.showDashboard()
|
||||||
|
} else {
|
||||||
|
a.showWizard()
|
||||||
|
}
|
||||||
|
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) saveSettings() {
|
func (a *App) hasSavedConfig() bool {
|
||||||
p := a.fyneApp.Preferences()
|
prefs := a.fyneApp.Preferences()
|
||||||
p.SetString("server_url", a.serverURL.Text)
|
return prefs.StringWithFallback("server_addr", "") != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) loadSettings() {
|
func (a *App) showWizard() {
|
||||||
p := a.fyneApp.Preferences()
|
a.state = StateSetup
|
||||||
if url := p.StringWithFallback("server_url", "http://localhost:7500"); url != "" {
|
a.wizard = NewWizard(a, a.onWizardComplete)
|
||||||
a.serverURL.SetText(url)
|
a.mainContainer.Objects = []fyne.CanvasObject{a.wizard.Content()}
|
||||||
}
|
a.mainContainer.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) setupUI() {
|
func (a *App) showDashboard() {
|
||||||
a.serverURL = widget.NewEntry()
|
a.state = StateDashboard
|
||||||
a.serverURL.SetText("http://localhost:7500")
|
a.dashboard = NewDashboard(a)
|
||||||
a.serverURL.PlaceHolder = "http://frps-server:7500"
|
|
||||||
|
|
||||||
a.authToken = widget.NewEntry()
|
prefs := a.fyneApp.Preferences()
|
||||||
a.authToken.PlaceHolder = "One-time token"
|
addr := prefs.StringWithFallback("server_addr", "127.0.0.1")
|
||||||
|
port := prefs.IntWithFallback("server_port", 7000)
|
||||||
|
a.dashboard.SetServerInfo(addr, port)
|
||||||
|
|
||||||
a.clientKey = widget.NewEntry()
|
toolbar := a.buildToolbar()
|
||||||
a.clientKey.PlaceHolder = "Client key (from server)"
|
|
||||||
|
|
||||||
a.statusLabel = widget.NewLabel("Not connected")
|
content := container.NewBorder(
|
||||||
a.statusLabel.Importance = widget.MediumImportance
|
toolbar,
|
||||||
|
nil, nil, nil,
|
||||||
a.configView = widget.NewMultiLineEntry()
|
a.dashboard.Content(),
|
||||||
a.configView.SetMinRowsVisible(8)
|
|
||||||
a.configView.Disable()
|
|
||||||
|
|
||||||
a.proxyList = widget.NewList(
|
|
||||||
func() int { return 0 },
|
|
||||||
func() fyne.CanvasObject { return widget.NewLabel("") },
|
|
||||||
func(id widget.ListItemID, o fyne.CanvasObject) {},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
a.connectBtn = widget.NewButtonWithIcon("Connect", theme.NavigateNextIcon(), a.onConnect)
|
a.mainContainer.Objects = []fyne.CanvasObject{content}
|
||||||
a.startBtn = widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), a.onStart)
|
a.mainContainer.Refresh()
|
||||||
a.startBtn.Disable()
|
|
||||||
a.stopBtn = widget.NewButtonWithIcon("Stop", theme.MediaStopIcon(), a.onStop)
|
|
||||||
a.stopBtn.Disable()
|
|
||||||
|
|
||||||
// Settings form
|
a.dashboard.AppendLog("Dashboard initialized")
|
||||||
settingsForm := widget.NewForm(
|
a.dashboard.AppendLog(fmt.Sprintf("Server: %s:%d", addr, port))
|
||||||
widget.NewFormItem("Server URL", a.serverURL),
|
|
||||||
)
|
|
||||||
settingsForm.OnSubmit = func() { a.saveSettings() }
|
|
||||||
settingsBtn := widget.NewButtonWithIcon("Save Settings", theme.DocumentSaveIcon(), a.saveSettings)
|
|
||||||
|
|
||||||
settingsTab := container.NewVBox(
|
a.tray.Start()
|
||||||
widget.NewLabelWithStyle("Connection Settings", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
}
|
||||||
settingsForm,
|
|
||||||
|
func (a *App) buildToolbar() fyne.CanvasObject {
|
||||||
|
logo := widget.NewLabelWithStyle("kanholec", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
ver := widget.NewLabel("v" + version.Full())
|
||||||
|
ver.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
settingsBtn := widget.NewButtonWithIcon("", theme.SettingsIcon(), func() {
|
||||||
|
a.showSettingsDialog()
|
||||||
|
})
|
||||||
|
settingsBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
serviceBtn := widget.NewButtonWithIcon("Service", nil, func() {
|
||||||
|
a.showServiceDialog()
|
||||||
|
})
|
||||||
|
|
||||||
|
resetBtn := widget.NewButtonWithIcon("Reset Setup", nil, func() {
|
||||||
|
dialog.ShowConfirm("Reset Setup",
|
||||||
|
"This will clear your configuration and restart the setup wizard. Continue?",
|
||||||
|
func(ok bool) {
|
||||||
|
if ok {
|
||||||
|
a.clearSavedConfig()
|
||||||
|
a.showWizard()
|
||||||
|
}
|
||||||
|
}, a.window)
|
||||||
|
})
|
||||||
|
resetBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
return container.NewVBox(
|
||||||
|
container.NewHBox(
|
||||||
|
logo,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
ver,
|
||||||
|
layout.NewSpacer(),
|
||||||
|
serviceBtn,
|
||||||
|
widget.NewLabel(""),
|
||||||
settingsBtn,
|
settingsBtn,
|
||||||
layout.NewSpacer(),
|
widget.NewLabel(""),
|
||||||
)
|
resetBtn,
|
||||||
|
|
||||||
// Auth form
|
|
||||||
authForm := widget.NewForm(
|
|
||||||
widget.NewFormItem("Server URL", a.serverURL),
|
|
||||||
widget.NewFormItem("One-Time Token", a.authToken),
|
|
||||||
widget.NewFormItem("Client Key", a.clientKey),
|
|
||||||
)
|
|
||||||
|
|
||||||
statusLine := container.NewHBox(
|
|
||||||
widget.NewLabelWithStyle("Status:", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
||||||
a.statusLabel,
|
|
||||||
layout.NewSpacer(),
|
|
||||||
a.connectBtn,
|
|
||||||
)
|
|
||||||
|
|
||||||
controls := container.NewHBox(
|
|
||||||
a.startBtn,
|
|
||||||
a.stopBtn,
|
|
||||||
)
|
|
||||||
|
|
||||||
connectTab := container.NewVBox(
|
|
||||||
widget.NewLabelWithStyle("frp Client GUI", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
||||||
authForm,
|
|
||||||
statusLine,
|
|
||||||
controls,
|
|
||||||
container.NewBorder(
|
|
||||||
widget.NewLabelWithStyle("Proxies", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
||||||
nil, nil, nil,
|
|
||||||
a.proxyList,
|
|
||||||
),
|
),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel(""),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config tab
|
|
||||||
configTab := container.NewBorder(
|
|
||||||
widget.NewLabelWithStyle("Configuration (read-only)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
||||||
nil, nil, nil,
|
|
||||||
a.configView,
|
|
||||||
)
|
|
||||||
|
|
||||||
tabs := container.NewAppTabs(
|
|
||||||
container.NewTabItemWithIcon("Connect", theme.ComputerIcon(), connectTab),
|
|
||||||
container.NewTabItemWithIcon("Config", theme.DocumentIcon(), configTab),
|
|
||||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsTab),
|
|
||||||
)
|
|
||||||
tabs.SelectIndex(0)
|
|
||||||
|
|
||||||
a.window.SetContent(tabs)
|
|
||||||
a.loadSettings()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) onConnect() {
|
func (a *App) onWizardComplete(result WizardResult) {
|
||||||
url := strings.TrimRight(a.serverURL.Text, "/")
|
prefs := a.fyneApp.Preferences()
|
||||||
token := a.authToken.Text
|
prefs.SetString("server_addr", result.ServerAddr)
|
||||||
key := a.clientKey.Text
|
|
||||||
a.saveSettings()
|
|
||||||
|
|
||||||
if token == "" && key == "" {
|
port, _ := strconv.Atoi(result.ServerPort)
|
||||||
a.statusLabel.SetText("Error: provide token or client key")
|
if port == 0 {
|
||||||
return
|
port = 7000
|
||||||
|
}
|
||||||
|
prefs.SetInt("server_port", port)
|
||||||
|
prefs.SetString("auth_method", result.AuthMethod)
|
||||||
|
prefs.SetString("auth_token", result.Token)
|
||||||
|
prefs.SetString("auth_user", result.Username)
|
||||||
|
prefs.SetString("auth_pass", result.Password)
|
||||||
|
prefs.SetString("client_name", result.ClientName)
|
||||||
|
|
||||||
|
a.showDashboard()
|
||||||
|
a.dashboard.AppendLog("Setup completed. Fetching configuration...")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
a.fetchAndSaveConfig(result)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
a.startClient()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) fetchAndSaveConfig(result WizardResult) {
|
||||||
|
serverURL := fmt.Sprintf("http://%s:%s", result.ServerAddr, "7500")
|
||||||
|
|
||||||
|
var configData []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
a.dashboard.AppendLog(fmt.Sprintf("Connecting to %s...", serverURL))
|
||||||
|
|
||||||
|
if result.AuthMethod == "Token" && result.Token != "" {
|
||||||
|
a.dashboard.AppendLog("Authenticating with token...")
|
||||||
|
configData, err = a.authWithToken(serverURL, result.Token)
|
||||||
|
} else if result.AuthMethod == "Username/Password" {
|
||||||
|
a.dashboard.AppendLog("Authenticating with credentials...")
|
||||||
|
configData, err = a.authWithCredentials(serverURL, result.Username, result.Password, result.ClientName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if token != "" {
|
|
||||||
apiURL := url + "/admin/api/client/auth"
|
|
||||||
body := fmt.Sprintf(`{"token":"%s"}`, token)
|
|
||||||
resp, err := http.Post(apiURL, "application/json", strings.NewReader(body))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
|
a.dashboard.AppendLog(fmt.Sprintf("Auth error: %v", err))
|
||||||
|
a.dashboard.AppendLog("You can still start the client with manual configuration.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if configData != nil {
|
||||||
|
a.configData = string(configData)
|
||||||
|
a.saveConfigToFile(configData)
|
||||||
|
a.dashboard.SetConfig(a.configData)
|
||||||
|
a.dashboard.AppendLog("Configuration fetched successfully")
|
||||||
|
a.dashboard.AppendLog("Configuration saved to disk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) authWithToken(serverURL, token string) ([]byte, error) {
|
||||||
|
apiURL := serverURL + "/admin/api/client/auth"
|
||||||
|
body, _ := json.Marshal(map[string]string{"token": token})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connection failed: %w", err)
|
||||||
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
a.statusLabel.SetText(fmt.Sprintf("Auth failed (%d): %s", resp.StatusCode, string(respBody)))
|
return nil, fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
configData, _ := io.ReadAll(resp.Body)
|
return io.ReadAll(resp.Body)
|
||||||
a.configData = string(configData)
|
}
|
||||||
a.configView.SetText(a.configData)
|
|
||||||
a.statusLabel.SetText("Connected (token auth)")
|
func (a *App) authWithCredentials(serverURL, username, password, clientName string) ([]byte, error) {
|
||||||
a.startBtn.Enable()
|
apiURL := serverURL + "/admin/api/client/auth"
|
||||||
a.connectBtn.SetText("Reconnect")
|
body, _ := json.Marshal(map[string]string{
|
||||||
} else if key != "" {
|
"username": username,
|
||||||
apiURL := fmt.Sprintf("%s/admin/api/frpc/proxy-config/%s", url, key)
|
"password": password,
|
||||||
resp, err := http.Get(apiURL)
|
"client_name": clientName,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
|
return nil, err
|
||||||
return
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connection failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
a.statusLabel.SetText(fmt.Sprintf("Config fetch failed (%d)", resp.StatusCode))
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveConfigToFile(data []byte) {
|
||||||
|
configDir := a.getConfigDir()
|
||||||
|
os.MkdirAll(configDir, 0755)
|
||||||
|
configPath := filepath.Join(configDir, "kanholec.toml")
|
||||||
|
os.WriteFile(configPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) getConfigDir() string {
|
||||||
|
if os.Getenv("ProgramData") != "" {
|
||||||
|
return filepath.Join(os.Getenv("ProgramData"), "kanholec")
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".kanholec")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadSavedConfig() ([]byte, error) {
|
||||||
|
configPath := filepath.Join(a.getConfigDir(), "kanholec.toml")
|
||||||
|
return os.ReadFile(configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) startClient() {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if a.running {
|
||||||
|
a.dashboard.AppendLog("Client is already running")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
configData, _ := io.ReadAll(resp.Body)
|
a.dashboard.AppendLog("Starting client...")
|
||||||
a.configData = string(configData)
|
|
||||||
a.configView.SetText(a.configData)
|
|
||||||
a.statusLabel.SetText("Connected (key auth)")
|
|
||||||
a.startBtn.Enable()
|
|
||||||
a.connectBtn.SetText("Reconnect")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) onStart() {
|
var cfgData []byte
|
||||||
if a.configData == "" {
|
var err error
|
||||||
a.statusLabel.SetText("Error: no config loaded")
|
|
||||||
|
if a.configData != "" {
|
||||||
|
a.dashboard.AppendLog("Using fetched configuration")
|
||||||
|
cfgData = []byte(a.configData)
|
||||||
|
} else {
|
||||||
|
a.dashboard.AppendLog("Loading saved configuration from disk...")
|
||||||
|
cfgData, err = a.loadSavedConfig()
|
||||||
|
if err != nil {
|
||||||
|
a.dashboard.AppendLog(fmt.Sprintf("No configuration available: %v", err))
|
||||||
|
a.dashboard.AppendLog("Please complete setup wizard or add manual configuration")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.dashboard.AppendLog("Configuration loaded from disk")
|
||||||
|
}
|
||||||
|
|
||||||
var cfg v1.ClientConfig
|
var cfg v1.ClientConfig
|
||||||
if err := config.LoadConfigure([]byte(a.configData), &cfg, false, "toml"); err != nil {
|
if err := config.LoadConfigure(cfgData, &cfg, false, "toml"); err != nil {
|
||||||
dialog.ShowError(fmt.Errorf("invalid config: %w", err), a.window)
|
a.dashboard.AppendLog(fmt.Sprintf("Invalid configuration: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.mu.Lock()
|
result := &config.ClientConfigLoadResult{
|
||||||
a.running = true
|
Common: &cfg.ClientCommonConfig,
|
||||||
a.mu.Unlock()
|
Proxies: make([]v1.ProxyConfigurer, 0),
|
||||||
|
}
|
||||||
a.startBtn.Disable()
|
for _, c := range cfg.Proxies {
|
||||||
a.stopBtn.Enable()
|
result.Proxies = append(result.Proxies, c.ProxyConfigurer)
|
||||||
a.statusLabel.SetText("Running")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) onStop() {
|
a.dashboard.AppendLog(fmt.Sprintf("Loaded %d proxies from configuration", len(result.Proxies)))
|
||||||
|
|
||||||
|
configSrc := source.NewConfigSource()
|
||||||
|
if err := configSrc.ReplaceAll(result.Proxies, result.Visitors); err != nil {
|
||||||
|
a.dashboard.AppendLog(fmt.Sprintf("Config source error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregator := source.NewAggregator(configSrc)
|
||||||
|
unsafeFeatures := security.NewUnsafeFeatures(nil)
|
||||||
|
|
||||||
|
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
a.cancelFunc = cancel
|
||||||
|
|
||||||
|
svr, err := client.NewService(client.ServiceOptions{
|
||||||
|
Common: &cfg.ClientCommonConfig,
|
||||||
|
ConfigSourceAggregator: aggregator,
|
||||||
|
UnsafeFeatures: unsafeFeatures,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
a.dashboard.AppendLog(fmt.Sprintf("Service creation failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.clientService = svr
|
||||||
|
a.running = true
|
||||||
|
|
||||||
|
a.dashboard.AppendLog("Client service created, connecting...")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
a.dashboard.SetRunning(true)
|
||||||
|
a.dashboard.AppendLog("Client started")
|
||||||
|
if err := svr.Run(ctx); err != nil {
|
||||||
|
a.dashboard.AppendLog(fmt.Sprintf("Client error: %v", err))
|
||||||
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.running = false
|
a.running = false
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
a.dashboard.SetRunning(false)
|
||||||
|
a.dashboard.AppendLog("Client stopped")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
a.startBtn.Enable()
|
func (a *App) stopClient() {
|
||||||
a.stopBtn.Disable()
|
a.mu.Lock()
|
||||||
a.statusLabel.SetText("Stopped")
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if !a.running || a.cancelFunc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.cancelFunc()
|
||||||
|
a.running = false
|
||||||
|
a.dashboard.SetRunning(false)
|
||||||
|
a.dashboard.AppendLog("Client stop requested")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) reconnect() {
|
||||||
|
a.stopClient()
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
prefs := a.fyneApp.Preferences()
|
||||||
|
result := WizardResult{
|
||||||
|
ServerAddr: prefs.StringWithFallback("server_addr", ""),
|
||||||
|
ServerPort: strconv.Itoa(prefs.IntWithFallback("server_port", 7000)),
|
||||||
|
AuthMethod: prefs.StringWithFallback("auth_method", "Token"),
|
||||||
|
Token: prefs.StringWithFallback("auth_token", ""),
|
||||||
|
Username: prefs.StringWithFallback("auth_user", ""),
|
||||||
|
Password: prefs.StringWithFallback("auth_pass", ""),
|
||||||
|
ClientName: prefs.StringWithFallback("client_name", ""),
|
||||||
|
}
|
||||||
|
a.fetchAndSaveConfig(result)
|
||||||
|
a.startClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showSettingsDialog() {
|
||||||
|
prefs := a.fyneApp.Preferences()
|
||||||
|
|
||||||
|
addrEntry := widget.NewEntry()
|
||||||
|
addrEntry.SetText(prefs.StringWithFallback("server_addr", ""))
|
||||||
|
|
||||||
|
portEntry := widget.NewEntry()
|
||||||
|
portEntry.SetText(strconv.Itoa(prefs.IntWithFallback("server_port", 7000)))
|
||||||
|
|
||||||
|
tokenEntry := widget.NewEntry()
|
||||||
|
tokenEntry.SetText(prefs.StringWithFallback("auth_token", ""))
|
||||||
|
|
||||||
|
form := dialog.NewForm("Settings", "Save", "Cancel",
|
||||||
|
[]*widget.FormItem{
|
||||||
|
widget.NewFormItem("Server Address", addrEntry),
|
||||||
|
widget.NewFormItem("Server Port", portEntry),
|
||||||
|
widget.NewFormItem("Auth Token", tokenEntry),
|
||||||
|
},
|
||||||
|
func(ok bool) {
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.SetString("server_addr", addrEntry.Text)
|
||||||
|
port, _ := strconv.Atoi(portEntry.Text)
|
||||||
|
if port > 0 {
|
||||||
|
prefs.SetInt("server_port", port)
|
||||||
|
}
|
||||||
|
prefs.SetString("auth_token", tokenEntry.Text)
|
||||||
|
a.dashboard.SetServerInfo(addrEntry.Text, port)
|
||||||
|
a.dashboard.AppendLog("Settings saved")
|
||||||
|
},
|
||||||
|
a.window,
|
||||||
|
)
|
||||||
|
form.Resize(fyne.NewSize(450, 300))
|
||||||
|
form.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showServiceDialog() {
|
||||||
|
installed := a.svcMgr.IsInstalled()
|
||||||
|
running := a.svcMgr.IsRunning()
|
||||||
|
|
||||||
|
statusText := "Not installed"
|
||||||
|
if installed {
|
||||||
|
if running {
|
||||||
|
statusText = "Installed and Running"
|
||||||
|
} else {
|
||||||
|
statusText = "Installed (Stopped)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel := widget.NewLabelWithStyle("Service: "+statusText, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
|
||||||
|
var buttons []fyne.CanvasObject
|
||||||
|
if !installed {
|
||||||
|
installBtn := widget.NewButton("Install Service", func() {
|
||||||
|
configPath := a.svcMgr.GetConfigPath()
|
||||||
|
if err := a.svcMgr.Install(configPath); err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Install failed: %v", err))
|
||||||
|
} else {
|
||||||
|
a.dashboard.AppendLog("Windows service installed")
|
||||||
|
statusLabel.SetText("Service: Installed (Stopped)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
installBtn.Importance = widget.HighImportance
|
||||||
|
buttons = append(buttons, installBtn)
|
||||||
|
} else {
|
||||||
|
if !running {
|
||||||
|
startBtn := widget.NewButton("Start Service", func() {
|
||||||
|
if err := a.svcMgr.Start(); err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Start failed: %v", err))
|
||||||
|
} else {
|
||||||
|
a.dashboard.AppendLog("Windows service started")
|
||||||
|
statusLabel.SetText("Service: Installed and Running")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
startBtn.Importance = widget.HighImportance
|
||||||
|
buttons = append(buttons, startBtn)
|
||||||
|
} else {
|
||||||
|
stopBtn := widget.NewButton("Stop Service", func() {
|
||||||
|
if err := a.svcMgr.Stop(); err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Stop failed: %v", err))
|
||||||
|
} else {
|
||||||
|
a.dashboard.AppendLog("Windows service stopped")
|
||||||
|
statusLabel.SetText("Service: Installed (Stopped)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
buttons = append(buttons, stopBtn)
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstallBtn := widget.NewButton("Uninstall Service", func() {
|
||||||
|
dialog.ShowConfirm("Uninstall Service",
|
||||||
|
"Remove the kanholec Windows service?",
|
||||||
|
func(ok bool) {
|
||||||
|
if ok {
|
||||||
|
if err := a.svcMgr.Uninstall(); err != nil {
|
||||||
|
a.showError(fmt.Sprintf("Uninstall failed: %v", err))
|
||||||
|
} else {
|
||||||
|
a.dashboard.AppendLog("Windows service uninstalled")
|
||||||
|
statusLabel.SetText("Service: Not installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, a.window)
|
||||||
|
})
|
||||||
|
uninstallBtn.Importance = widget.DangerImportance
|
||||||
|
buttons = append(buttons, uninstallBtn)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := container.NewVBox(
|
||||||
|
statusLabel,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
container.NewHBox(buttons...),
|
||||||
|
)
|
||||||
|
|
||||||
|
d := dialog.NewCustom("Windows Service", "Close", content, a.window)
|
||||||
|
d.Resize(fyne.NewSize(400, 200))
|
||||||
|
d.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) showError(msg string) {
|
||||||
|
if a.window != nil {
|
||||||
|
dialog.ShowError(fmt.Errorf("%s", msg), a.window)
|
||||||
|
}
|
||||||
|
if a.dashboard != nil {
|
||||||
|
a.dashboard.AppendLog("Error: " + msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) clearSavedConfig() {
|
||||||
|
prefs := a.fyneApp.Preferences()
|
||||||
|
prefs.SetString("server_addr", "")
|
||||||
|
prefs.SetInt("server_port", 0)
|
||||||
|
prefs.SetString("auth_method", "")
|
||||||
|
prefs.SetString("auth_token", "")
|
||||||
|
prefs.SetString("auth_user", "")
|
||||||
|
prefs.SetString("auth_pass", "")
|
||||||
|
prefs.SetString("client_name", "")
|
||||||
|
a.configData = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Quit() {
|
||||||
|
a.stopClient()
|
||||||
|
a.tray.Stop()
|
||||||
|
a.fyneApp.Quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Run() {
|
func (a *App) Run() {
|
||||||
@@ -249,6 +576,6 @@ func (a *App) Run() {
|
|||||||
|
|
||||||
func Run() {
|
func Run() {
|
||||||
gui := New()
|
gui := New()
|
||||||
log.Infof("starting frpc GUI")
|
log.Infof("starting kanholec GUI v%s", version.Full())
|
||||||
gui.Run()
|
gui.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
//go:build kanholec_gui
|
||||||
|
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Dashboard struct {
|
||||||
|
app *App
|
||||||
|
|
||||||
|
statusDot *canvas.Circle
|
||||||
|
statusLabel *widget.Label
|
||||||
|
statusDetail *widget.Label
|
||||||
|
|
||||||
|
proxyList *widget.List
|
||||||
|
proxyData []ProxyInfo
|
||||||
|
proxyDataMu sync.Mutex
|
||||||
|
|
||||||
|
logView *widget.Entry
|
||||||
|
logLines []string
|
||||||
|
logMu sync.Mutex
|
||||||
|
|
||||||
|
startBtn *widget.Button
|
||||||
|
stopBtn *widget.Button
|
||||||
|
reconnectBtn *widget.Button
|
||||||
|
|
||||||
|
serverInfo *widget.Label
|
||||||
|
configView *widget.Entry
|
||||||
|
|
||||||
|
content *fyne.Container
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyInfo struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Status string
|
||||||
|
LocalAddr string
|
||||||
|
RemoteAddr string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboard(app *App) *Dashboard {
|
||||||
|
d := &Dashboard{
|
||||||
|
app: app,
|
||||||
|
}
|
||||||
|
d.buildUI()
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) buildUI() {
|
||||||
|
d.statusDot = canvas.NewCircle(colorPlaceholder)
|
||||||
|
d.statusDot.Resize(fyne.NewSize(16, 16))
|
||||||
|
|
||||||
|
d.statusLabel = widget.NewLabelWithStyle("Disconnected", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
d.statusDetail = widget.NewLabel("Not connected to server")
|
||||||
|
d.statusDetail.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
d.serverInfo = widget.NewLabel("Server: --")
|
||||||
|
d.serverInfo.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
d.configView = widget.NewMultiLineEntry()
|
||||||
|
d.configView.SetMinRowsVisible(10)
|
||||||
|
d.configView.Disable()
|
||||||
|
d.configView.SetText("No configuration loaded yet...")
|
||||||
|
|
||||||
|
d.logView = widget.NewMultiLineEntry()
|
||||||
|
d.logView.SetMinRowsVisible(15)
|
||||||
|
d.logView.Disable()
|
||||||
|
d.logView.Wrapping = fyne.TextWrapWord
|
||||||
|
d.logView.SetText("Waiting for logs...\n")
|
||||||
|
|
||||||
|
d.proxyData = []ProxyInfo{}
|
||||||
|
d.proxyList = widget.NewList(
|
||||||
|
func() int {
|
||||||
|
d.proxyDataMu.Lock()
|
||||||
|
defer d.proxyDataMu.Unlock()
|
||||||
|
return len(d.proxyData)
|
||||||
|
},
|
||||||
|
func() fyne.CanvasObject {
|
||||||
|
name := widget.NewLabelWithStyle("proxy-name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
typ := widget.NewLabel("tcp")
|
||||||
|
typ.Importance = widget.LowImportance
|
||||||
|
status := widget.NewLabel("unknown")
|
||||||
|
local := widget.NewLabel("127.0.0.1:8080")
|
||||||
|
remote := widget.NewLabel("0.0.0.0:9090")
|
||||||
|
errLabel := widget.NewLabel("")
|
||||||
|
errLabel.Importance = widget.DangerImportance
|
||||||
|
|
||||||
|
return container.NewVBox(
|
||||||
|
container.NewHBox(name, layout.NewSpacer(), status),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
container.NewHBox(typ, widget.NewSeparator(), local, widget.NewSeparator(), remote),
|
||||||
|
errLabel,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
func(id widget.ListItemID, o fyne.CanvasObject) {
|
||||||
|
d.proxyDataMu.Lock()
|
||||||
|
defer d.proxyDataMu.Unlock()
|
||||||
|
if id >= len(d.proxyData) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := d.proxyData[id]
|
||||||
|
vbox := o.(*fyne.Container)
|
||||||
|
|
||||||
|
row1 := vbox.Objects[0].(*fyne.Container)
|
||||||
|
nameLabel := row1.Objects[0].(*widget.Label)
|
||||||
|
nameLabel.SetText(p.Name)
|
||||||
|
statusLabel := row1.Objects[2].(*widget.Label)
|
||||||
|
statusLabel.SetText(p.Status)
|
||||||
|
statusLabel.Importance = widget.MediumImportance
|
||||||
|
|
||||||
|
row2 := vbox.Objects[2].(*fyne.Container)
|
||||||
|
typLabel := row2.Objects[0].(*widget.Label)
|
||||||
|
typLabel.SetText(p.Type)
|
||||||
|
localLabel := row2.Objects[2].(*widget.Label)
|
||||||
|
localLabel.SetText(p.LocalAddr)
|
||||||
|
remoteLabel := row2.Objects[4].(*widget.Label)
|
||||||
|
remoteLabel.SetText(p.RemoteAddr)
|
||||||
|
|
||||||
|
errLabel := vbox.Objects[3].(*widget.Label)
|
||||||
|
errLabel.SetText(p.Error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
d.startBtn = widget.NewButtonWithIcon("Start", nil, d.onStart)
|
||||||
|
d.startBtn.Importance = widget.HighImportance
|
||||||
|
d.stopBtn = widget.NewButtonWithIcon("Stop", nil, d.onStop)
|
||||||
|
d.stopBtn.Importance = widget.DangerImportance
|
||||||
|
d.stopBtn.Disable()
|
||||||
|
d.reconnectBtn = widget.NewButtonWithIcon("Reconnect", nil, d.onReconnect)
|
||||||
|
|
||||||
|
statusCard := container.NewVBox(
|
||||||
|
container.NewHBox(
|
||||||
|
d.statusDot,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
d.statusLabel,
|
||||||
|
layout.NewSpacer(),
|
||||||
|
d.serverInfo,
|
||||||
|
),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
d.statusDetail,
|
||||||
|
)
|
||||||
|
|
||||||
|
controls := container.NewHBox(
|
||||||
|
d.startBtn,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
d.stopBtn,
|
||||||
|
layout.NewSpacer(),
|
||||||
|
d.reconnectBtn,
|
||||||
|
)
|
||||||
|
|
||||||
|
proxiesTab := container.NewBorder(
|
||||||
|
container.NewVBox(
|
||||||
|
container.NewHBox(
|
||||||
|
widget.NewLabelWithStyle("Proxies", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
widget.NewButton("Refresh", func() { d.refreshProxies() }),
|
||||||
|
),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
),
|
||||||
|
nil, nil, nil,
|
||||||
|
container.NewStack(
|
||||||
|
d.proxyList,
|
||||||
|
container.NewVBox(
|
||||||
|
layout.NewSpacer(),
|
||||||
|
container.NewHBox(
|
||||||
|
layout.NewSpacer(),
|
||||||
|
widget.NewLabel("No proxies configured yet. Start the client to connect."),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logTab := container.NewBorder(
|
||||||
|
container.NewVBox(
|
||||||
|
container.NewHBox(
|
||||||
|
widget.NewLabelWithStyle("Logs", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
widget.NewButton("Clear", func() { d.clearLogs() }),
|
||||||
|
),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
),
|
||||||
|
nil, nil, nil,
|
||||||
|
d.logView,
|
||||||
|
)
|
||||||
|
|
||||||
|
configTab := container.NewBorder(
|
||||||
|
container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("Active Configuration", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
),
|
||||||
|
nil, nil, nil,
|
||||||
|
d.configView,
|
||||||
|
)
|
||||||
|
|
||||||
|
tabs := container.NewAppTabs(
|
||||||
|
container.NewTabItem("Proxies", proxiesTab),
|
||||||
|
container.NewTabItem("Logs", logTab),
|
||||||
|
container.NewTabItem("Config", configTab),
|
||||||
|
)
|
||||||
|
tabs.SelectIndex(0)
|
||||||
|
|
||||||
|
d.content = container.NewBorder(
|
||||||
|
container.NewVBox(
|
||||||
|
statusCard,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
controls,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
),
|
||||||
|
nil, nil, nil,
|
||||||
|
tabs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) Content() *fyne.Container {
|
||||||
|
return d.content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) SetStatus(status string, detail string) {
|
||||||
|
d.statusLabel.SetText(status)
|
||||||
|
d.statusDetail.SetText(detail)
|
||||||
|
|
||||||
|
c := StatusColor(status)
|
||||||
|
d.statusDot.FillColor = c
|
||||||
|
d.statusDot.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) SetServerInfo(addr string, port int) {
|
||||||
|
d.serverInfo.SetText(fmt.Sprintf("Server: %s:%d", addr, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) SetConfig(data string) {
|
||||||
|
d.configView.SetText(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) AppendLog(line string) {
|
||||||
|
d.logMu.Lock()
|
||||||
|
d.logLines = append(d.logLines, line)
|
||||||
|
if len(d.logLines) > 500 {
|
||||||
|
d.logLines = d.logLines[len(d.logLines)-500:]
|
||||||
|
}
|
||||||
|
d.logMu.Unlock()
|
||||||
|
|
||||||
|
text := ""
|
||||||
|
d.logMu.Lock()
|
||||||
|
for _, l := range d.logLines {
|
||||||
|
text += l + "\n"
|
||||||
|
}
|
||||||
|
d.logMu.Unlock()
|
||||||
|
d.logView.SetText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) clearLogs() {
|
||||||
|
d.logMu.Lock()
|
||||||
|
d.logLines = nil
|
||||||
|
d.logMu.Unlock()
|
||||||
|
d.logView.SetText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) SetProxies(proxies []ProxyInfo) {
|
||||||
|
d.proxyDataMu.Lock()
|
||||||
|
d.proxyData = proxies
|
||||||
|
d.proxyDataMu.Unlock()
|
||||||
|
d.proxyList.Refresh()
|
||||||
|
if len(proxies) > 0 {
|
||||||
|
d.AppendLog(fmt.Sprintf("Loaded %d proxies", len(proxies)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) refreshProxies() {
|
||||||
|
if d.app != nil && d.app.clientService != nil {
|
||||||
|
d.AppendLog("Refreshing proxy status...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) onStart() {
|
||||||
|
d.app.startClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) onStop() {
|
||||||
|
d.app.stopClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) onReconnect() {
|
||||||
|
d.app.reconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) SetRunning(running bool) {
|
||||||
|
if running {
|
||||||
|
d.startBtn.Disable()
|
||||||
|
d.stopBtn.Enable()
|
||||||
|
d.SetStatus("running", "Client is connected and running")
|
||||||
|
d.statusDot.FillColor = color.NRGBA{R: 34, G: 197, B: 94, A: 255}
|
||||||
|
} else {
|
||||||
|
d.startBtn.Enable()
|
||||||
|
d.stopBtn.Disable()
|
||||||
|
d.SetStatus("stopped", "Client is not running")
|
||||||
|
d.statusDot.FillColor = color.NRGBA{R: 239, G: 68, B: 68, A: 255}
|
||||||
|
}
|
||||||
|
d.statusDot.Refresh()
|
||||||
|
|
||||||
|
if d.app != nil && d.app.tray != nil {
|
||||||
|
d.app.tray.SetRunning(running)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//go:build kanholec_gui
|
||||||
|
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceManager struct {
|
||||||
|
binaryPath string
|
||||||
|
serviceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServiceManager() *ServiceManager {
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
return &ServiceManager{
|
||||||
|
binaryPath: exe,
|
||||||
|
serviceName: "kanholec",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceManager) IsInstalled() bool {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cmd := exec.Command("sc", "query", s.serviceName)
|
||||||
|
err := cmd.Run()
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceManager) Install(configPath string) error {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return fmt.Errorf("service installation is only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.IsInstalled() {
|
||||||
|
return fmt.Errorf("service %q is already installed", s.serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"create", s.serviceName,
|
||||||
|
"binPath=", fmt.Sprintf(`"%s" -c "%s"`, s.binaryPath, configPath),
|
||||||
|
"DisplayName=", "kanholec - kanhole Client",
|
||||||
|
"start=", "auto",
|
||||||
|
"obj=", "LocalSystem",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("sc", args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to install service: %s: %w", string(out), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
descArgs := []string{"description", s.serviceName, "kanhole reverse proxy client service"}
|
||||||
|
cmd = exec.Command("sc", descArgs...)
|
||||||
|
cmd.Run()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceManager) Uninstall() error {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return fmt.Errorf("service management is only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.IsInstalled() {
|
||||||
|
return fmt.Errorf("service %q is not installed", s.serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Stop()
|
||||||
|
|
||||||
|
cmd := exec.Command("sc", "delete", s.serviceName)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to uninstall service: %s: %w", string(out), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceManager) Start() error {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return fmt.Errorf("service management is only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("sc", "start", s.serviceName)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start service: %s: %w", string(out), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceManager) Stop() error {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return fmt.Errorf("service management is only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("sc", "stop", s.serviceName)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stop service: %s: %w", string(out), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceManager) IsRunning() bool {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cmd := exec.Command("sc", "query", s.serviceName)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return contains(string(out), "RUNNING")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceManager) GetConfigPath() string {
|
||||||
|
programData := os.Getenv("ProgramData")
|
||||||
|
if programData == "" {
|
||||||
|
programData = `C:\ProgramData`
|
||||||
|
}
|
||||||
|
return filepath.Join(programData, "kanholec", "kanholec.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && searchString(s, substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchString(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//go:build kanholec_gui
|
||||||
|
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
colorPrimary = color.NRGBA{R: 99, G: 102, B: 241, A: 255}
|
||||||
|
colorPrimaryDark = color.NRGBA{R: 79, G: 70, B: 229, A: 255}
|
||||||
|
colorSecondary = color.NRGBA{R: 236, G: 72, B: 153, A: 255}
|
||||||
|
colorBackground = color.NRGBA{R: 17, G: 24, B: 39, A: 255}
|
||||||
|
colorSurface = color.NRGBA{R: 31, G: 41, B: 55, A: 255}
|
||||||
|
colorForeground = color.NRGBA{R: 243, G: 244, B: 246, A: 255}
|
||||||
|
colorButton = color.NRGBA{R: 55, G: 65, B: 81, A: 255}
|
||||||
|
colorHover = color.NRGBA{R: 75, G: 85, B: 99, A: 255}
|
||||||
|
colorDisabled = color.NRGBA{R: 107, G: 114, B: 128, A: 255}
|
||||||
|
colorInputBorder = color.NRGBA{R: 75, G: 85, B: 99, A: 255}
|
||||||
|
colorPlaceholder = color.NRGBA{R: 156, G: 163, B: 175, A: 255}
|
||||||
|
colorSuccess = color.NRGBA{R: 34, G: 197, B: 94, A: 255}
|
||||||
|
colorWarning = color.NRGBA{R: 251, G: 146, B: 60, A: 255}
|
||||||
|
colorError = color.NRGBA{R: 239, G: 68, B: 68, A: 255}
|
||||||
|
colorSeparator = color.NRGBA{R: 55, G: 65, B: 81, A: 255}
|
||||||
|
colorAccent = color.NRGBA{R: 139, G: 92, B: 246, A: 255}
|
||||||
|
)
|
||||||
|
|
||||||
|
type kanholeTheme struct{}
|
||||||
|
|
||||||
|
var _ fyne.Theme = (*kanholeTheme)(nil)
|
||||||
|
|
||||||
|
func (t *kanholeTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
||||||
|
switch name {
|
||||||
|
case theme.ColorNameBackground:
|
||||||
|
return colorBackground
|
||||||
|
case theme.ColorNameForeground, theme.ColorNameForegroundOnPrimary:
|
||||||
|
return colorForeground
|
||||||
|
case theme.ColorNamePrimary:
|
||||||
|
return colorPrimary
|
||||||
|
case theme.ColorNameButton:
|
||||||
|
return colorButton
|
||||||
|
case theme.ColorNameHover:
|
||||||
|
return colorHover
|
||||||
|
case theme.ColorNameDisabled:
|
||||||
|
return colorDisabled
|
||||||
|
case theme.ColorNameDisabledButton:
|
||||||
|
return color.NRGBA{R: 45, G: 55, B: 72, A: 255}
|
||||||
|
case theme.ColorNameInputBorder:
|
||||||
|
return colorInputBorder
|
||||||
|
case theme.ColorNamePlaceHolder:
|
||||||
|
return colorPlaceholder
|
||||||
|
case theme.ColorNameScrollBar:
|
||||||
|
return color.NRGBA{R: 107, G: 114, B: 128, A: 180}
|
||||||
|
case theme.ColorNameShadow:
|
||||||
|
return color.NRGBA{R: 0, G: 0, B: 0, A: 100}
|
||||||
|
case theme.ColorNameSeparator:
|
||||||
|
return colorSeparator
|
||||||
|
case theme.ColorNameFocus:
|
||||||
|
return color.NRGBA{R: 99, G: 102, B: 241, A: 140}
|
||||||
|
case theme.ColorNameSelection:
|
||||||
|
return color.NRGBA{R: 99, G: 102, B: 241, A: 80}
|
||||||
|
case theme.ColorNameInputBackground:
|
||||||
|
return colorSurface
|
||||||
|
case theme.ColorNameHeaderBackground:
|
||||||
|
return color.NRGBA{R: 17, G: 24, B: 39, A: 255}
|
||||||
|
default:
|
||||||
|
return theme.DefaultTheme().Color(name, theme.VariantDark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *kanholeTheme) Font(style fyne.TextStyle) fyne.Resource {
|
||||||
|
return theme.DefaultTheme().Font(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *kanholeTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
|
||||||
|
return theme.DefaultTheme().Icon(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *kanholeTheme) Size(name fyne.ThemeSizeName) float32 {
|
||||||
|
switch name {
|
||||||
|
case theme.SizeNamePadding:
|
||||||
|
return 10
|
||||||
|
case theme.SizeNameInnerPadding:
|
||||||
|
return 8
|
||||||
|
case theme.SizeNameLineSpacing:
|
||||||
|
return 32
|
||||||
|
case theme.SizeNameScrollBar:
|
||||||
|
return 10
|
||||||
|
case theme.SizeNameScrollBarSmall:
|
||||||
|
return 5
|
||||||
|
case theme.SizeNameText:
|
||||||
|
return 14
|
||||||
|
case theme.SizeNameHeadingText:
|
||||||
|
return 28
|
||||||
|
case theme.SizeNameSubHeadingText:
|
||||||
|
return 18
|
||||||
|
case theme.SizeNameCaptionText:
|
||||||
|
return 12
|
||||||
|
case theme.SizeNameInputBorder:
|
||||||
|
return 2
|
||||||
|
case theme.SizeNameInputRadius:
|
||||||
|
return 10
|
||||||
|
case theme.SizeNameSelectionRadius:
|
||||||
|
return 8
|
||||||
|
default:
|
||||||
|
return theme.DefaultTheme().Size(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StatusColor(s string) color.Color {
|
||||||
|
switch s {
|
||||||
|
case "running", "connected", "ok", "active":
|
||||||
|
return colorSuccess
|
||||||
|
case "warning", "reconnecting":
|
||||||
|
return colorWarning
|
||||||
|
case "error", "stopped", "failed", "disconnected":
|
||||||
|
return colorError
|
||||||
|
default:
|
||||||
|
return colorPlaceholder
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//go:build kanholec_gui
|
||||||
|
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/systray"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrayManager struct {
|
||||||
|
app *App
|
||||||
|
running bool
|
||||||
|
|
||||||
|
mShow *systray.MenuItem
|
||||||
|
mStatus *systray.MenuItem
|
||||||
|
mStart *systray.MenuItem
|
||||||
|
mStop *systray.MenuItem
|
||||||
|
mQuit *systray.MenuItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTrayManager(app *App) *TrayManager {
|
||||||
|
return &TrayManager{app: app}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrayManager) Start() {
|
||||||
|
go t.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrayManager) run() {
|
||||||
|
defer func() {
|
||||||
|
recover()
|
||||||
|
}()
|
||||||
|
systray.Run(t.onReady, t.onExit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrayManager) onReady() {
|
||||||
|
t.running = true
|
||||||
|
|
||||||
|
systray.SetIcon(trayIconData())
|
||||||
|
systray.SetTitle("kanholec")
|
||||||
|
systray.SetTooltip("kanholec - kanhole Client")
|
||||||
|
|
||||||
|
t.mShow = systray.AddMenuItem("Show Dashboard", "Open kanholec GUI")
|
||||||
|
systray.AddSeparator()
|
||||||
|
t.mStatus = systray.AddMenuItem("Status: Disconnected", "")
|
||||||
|
t.mStatus.Disable()
|
||||||
|
systray.AddSeparator()
|
||||||
|
t.mStart = systray.AddMenuItem("Start Client", "Start the kanholec client")
|
||||||
|
t.mStop = systray.AddMenuItem("Stop Client", "Stop the kanholec client")
|
||||||
|
t.mStop.Disable()
|
||||||
|
systray.AddSeparator()
|
||||||
|
t.mQuit = systray.AddMenuItem("Quit", "Exit kanholec")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.mShow.ClickedCh:
|
||||||
|
if t.app.window != nil {
|
||||||
|
t.app.window.Show()
|
||||||
|
t.app.window.RequestFocus()
|
||||||
|
}
|
||||||
|
case <-t.mStart.ClickedCh:
|
||||||
|
t.app.startClient()
|
||||||
|
t.mStart.Disable()
|
||||||
|
t.mStop.Enable()
|
||||||
|
t.mStatus.SetTitle("Status: Running")
|
||||||
|
case <-t.mStop.ClickedCh:
|
||||||
|
t.app.stopClient()
|
||||||
|
t.mStart.Enable()
|
||||||
|
t.mStop.Disable()
|
||||||
|
t.mStatus.SetTitle("Status: Stopped")
|
||||||
|
case <-t.mQuit.ClickedCh:
|
||||||
|
t.app.Quit()
|
||||||
|
systray.Quit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrayManager) onExit() {
|
||||||
|
t.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrayManager) SetStatus(status string) {
|
||||||
|
if !t.running || t.mStatus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.mStatus.SetTitle("Status: " + status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrayManager) SetRunning(running bool) {
|
||||||
|
if !t.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
t.mStart.Disable()
|
||||||
|
t.mStop.Enable()
|
||||||
|
t.mStatus.SetTitle("Status: Running")
|
||||||
|
} else {
|
||||||
|
t.mStart.Enable()
|
||||||
|
t.mStop.Disable()
|
||||||
|
t.mStatus.SetTitle("Status: Stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrayManager) Stop() {
|
||||||
|
if t.running {
|
||||||
|
systray.Quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrayManager) IsSupported() bool {
|
||||||
|
return t.running
|
||||||
|
}
|
||||||
|
|
||||||
|
func trayIconResource() *fyne.StaticResource {
|
||||||
|
return &fyne.StaticResource{
|
||||||
|
StaticName: "kanholec.ico",
|
||||||
|
StaticContent: trayIconData(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trayIconData() []byte {
|
||||||
|
return []byte{
|
||||||
|
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10, 0x00, 0x00, 0x01, 0x00,
|
||||||
|
0x20, 0x00, 0x68, 0x04, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00, 0x28, 0x00,
|
||||||
|
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x01, 0x00,
|
||||||
|
0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
//go:build kanholec_gui
|
||||||
|
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
type wizardStep int
|
||||||
|
|
||||||
|
const (
|
||||||
|
stepWelcome wizardStep = iota
|
||||||
|
stepServer
|
||||||
|
stepAuth
|
||||||
|
stepFinish
|
||||||
|
)
|
||||||
|
|
||||||
|
type Wizard struct {
|
||||||
|
app *App
|
||||||
|
currentStep wizardStep
|
||||||
|
container *fyne.Container
|
||||||
|
stepIndicator *fyne.Container
|
||||||
|
|
||||||
|
serverAddr *widget.Entry
|
||||||
|
serverPort *widget.Entry
|
||||||
|
authMethod *widget.Select
|
||||||
|
authToken *widget.Entry
|
||||||
|
authUser *widget.Entry
|
||||||
|
authPass *widget.Entry
|
||||||
|
clientName *widget.Entry
|
||||||
|
|
||||||
|
onComplete func(cfg WizardResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WizardResult struct {
|
||||||
|
ServerAddr string
|
||||||
|
ServerPort string
|
||||||
|
AuthMethod string
|
||||||
|
Token string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
ClientName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWizard(app *App, onComplete func(WizardResult)) *Wizard {
|
||||||
|
w := &Wizard{
|
||||||
|
app: app,
|
||||||
|
onComplete: onComplete,
|
||||||
|
}
|
||||||
|
w.buildUI()
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wizard) buildUI() {
|
||||||
|
w.serverAddr = widget.NewEntry()
|
||||||
|
w.serverAddr.PlaceHolder = "127.0.0.1"
|
||||||
|
w.serverAddr.SetText("127.0.0.1")
|
||||||
|
|
||||||
|
w.serverPort = widget.NewEntry()
|
||||||
|
w.serverPort.PlaceHolder = "7000"
|
||||||
|
w.serverPort.SetText("7000")
|
||||||
|
|
||||||
|
w.authMethod = widget.NewSelect([]string{"Token", "Username/Password"}, nil)
|
||||||
|
w.authMethod.SetSelected("Token")
|
||||||
|
|
||||||
|
w.authToken = widget.NewEntry()
|
||||||
|
w.authToken.PlaceHolder = "One-time provisioning token"
|
||||||
|
|
||||||
|
w.authUser = widget.NewEntry()
|
||||||
|
w.authUser.PlaceHolder = "admin"
|
||||||
|
|
||||||
|
w.authPass = widget.NewPasswordEntry()
|
||||||
|
w.authPass.PlaceHolder = "Password"
|
||||||
|
|
||||||
|
w.clientName = widget.NewEntry()
|
||||||
|
w.clientName.PlaceHolder = "my-client"
|
||||||
|
|
||||||
|
w.authMethod.OnChanged = func(s string) {
|
||||||
|
if s == "Token" {
|
||||||
|
w.authToken.Show()
|
||||||
|
w.authUser.Hide()
|
||||||
|
w.authPass.Hide()
|
||||||
|
w.clientName.Hide()
|
||||||
|
} else {
|
||||||
|
w.authToken.Hide()
|
||||||
|
w.authUser.Show()
|
||||||
|
w.authPass.Show()
|
||||||
|
w.clientName.Show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.container = container.NewStack()
|
||||||
|
w.buildStepIndicator()
|
||||||
|
w.showStep(stepWelcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wizard) buildStepIndicator() {
|
||||||
|
labels := []string{"Welcome", "Server", "Auth", "Finish"}
|
||||||
|
items := []fyne.CanvasObject{}
|
||||||
|
|
||||||
|
for i, label := range labels {
|
||||||
|
isActive := i == int(w.currentStep)
|
||||||
|
isCompleted := i < int(w.currentStep)
|
||||||
|
|
||||||
|
var dotColor color.Color
|
||||||
|
if isActive {
|
||||||
|
dotColor = colorPrimary
|
||||||
|
} else if isCompleted {
|
||||||
|
dotColor = colorSuccess
|
||||||
|
} else {
|
||||||
|
dotColor = color.NRGBA{R: 75, G: 85, B: 99, A: 255}
|
||||||
|
}
|
||||||
|
|
||||||
|
dot := canvas.NewCircle(dotColor)
|
||||||
|
dot.Resize(fyne.NewSize(16, 16))
|
||||||
|
|
||||||
|
lbl := widget.NewLabel(label)
|
||||||
|
lbl.Alignment = fyne.TextAlignCenter
|
||||||
|
if isActive {
|
||||||
|
lbl.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
lbl.Importance = widget.HighImportance
|
||||||
|
} else if isCompleted {
|
||||||
|
lbl.Importance = widget.MediumImportance
|
||||||
|
} else {
|
||||||
|
lbl.Importance = widget.LowImportance
|
||||||
|
}
|
||||||
|
|
||||||
|
stepItem := container.NewVBox(
|
||||||
|
container.NewHBox(layout.NewSpacer(), dot, layout.NewSpacer()),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
container.NewHBox(layout.NewSpacer(), lbl, layout.NewSpacer()),
|
||||||
|
)
|
||||||
|
items = append(items, stepItem)
|
||||||
|
|
||||||
|
if i < len(labels)-1 {
|
||||||
|
lineColor := color.NRGBA{R: 75, G: 85, B: 99, A: 255}
|
||||||
|
if isCompleted {
|
||||||
|
lineColor = colorSuccess
|
||||||
|
}
|
||||||
|
line := canvas.NewRectangle(lineColor)
|
||||||
|
line.SetMinSize(fyne.NewSize(60, 3))
|
||||||
|
items = append(items, container.NewVBox(
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
container.NewHBox(layout.NewSpacer(), line, layout.NewSpacer()),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.stepIndicator = container.NewHBox(items...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wizard) refreshStepIndicator() {
|
||||||
|
w.buildStepIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wizard) showStep(step wizardStep) {
|
||||||
|
w.currentStep = step
|
||||||
|
w.refreshStepIndicator()
|
||||||
|
|
||||||
|
var content fyne.CanvasObject
|
||||||
|
switch step {
|
||||||
|
case stepWelcome:
|
||||||
|
content = w.welcomePage()
|
||||||
|
case stepServer:
|
||||||
|
content = w.serverPage()
|
||||||
|
case stepAuth:
|
||||||
|
content = w.authPage()
|
||||||
|
case stepFinish:
|
||||||
|
content = w.finishPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
inner := container.NewBorder(
|
||||||
|
container.NewVBox(
|
||||||
|
widget.NewLabel(""),
|
||||||
|
container.NewHBox(layout.NewSpacer(), w.stepIndicator, layout.NewSpacer()),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
nil, nil,
|
||||||
|
container.NewVScroll(content),
|
||||||
|
)
|
||||||
|
|
||||||
|
w.container.Objects = []fyne.CanvasObject{inner}
|
||||||
|
w.container.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wizard) welcomePage() fyne.CanvasObject {
|
||||||
|
title := canvas.NewText("Welcome to kanholec", colorForeground)
|
||||||
|
title.TextSize = 32
|
||||||
|
title.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
title.Alignment = fyne.TextAlignCenter
|
||||||
|
|
||||||
|
subtitle := canvas.NewText("Set up your reverse proxy client", colorPlaceholder)
|
||||||
|
subtitle.TextSize = 16
|
||||||
|
subtitle.Alignment = fyne.TextAlignCenter
|
||||||
|
|
||||||
|
desc := widget.NewRichTextFromMarkdown(`kanholec connects to a kanhole server to create secure tunnels.
|
||||||
|
|
||||||
|
**You will need:**
|
||||||
|
- A kanhole server address and port
|
||||||
|
- An authentication token or admin credentials
|
||||||
|
|
||||||
|
Click **Next** to get started.`)
|
||||||
|
|
||||||
|
nextBtn := widget.NewButton("Next", func() {
|
||||||
|
w.showStep(stepServer)
|
||||||
|
})
|
||||||
|
nextBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
return container.NewVBox(
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
container.NewHBox(layout.NewSpacer(), title, layout.NewSpacer()),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
container.NewHBox(layout.NewSpacer(), subtitle, layout.NewSpacer()),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
desc,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
container.NewHBox(layout.NewSpacer(), nextBtn),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wizard) serverPage() fyne.CanvasObject {
|
||||||
|
title := canvas.NewText("Server Connection", colorForeground)
|
||||||
|
title.TextSize = 26
|
||||||
|
title.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
|
||||||
|
desc := canvas.NewText("Enter your kanhole server details", colorPlaceholder)
|
||||||
|
desc.TextSize = 15
|
||||||
|
|
||||||
|
form := widget.NewForm(
|
||||||
|
widget.NewFormItem("Server Address", w.serverAddr),
|
||||||
|
widget.NewFormItem("Server Port", w.serverPort),
|
||||||
|
)
|
||||||
|
|
||||||
|
backBtn := widget.NewButton("Back", func() {
|
||||||
|
w.showStep(stepWelcome)
|
||||||
|
})
|
||||||
|
nextBtn := widget.NewButton("Next", func() {
|
||||||
|
if w.serverAddr.Text == "" {
|
||||||
|
w.app.showError("Server address is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.showStep(stepAuth)
|
||||||
|
})
|
||||||
|
nextBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
return container.NewVBox(
|
||||||
|
title,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
desc,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
form,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
container.NewHBox(backBtn, layout.NewSpacer(), nextBtn),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wizard) authPage() fyne.CanvasObject {
|
||||||
|
title := canvas.NewText("Authentication", colorForeground)
|
||||||
|
title.TextSize = 26
|
||||||
|
title.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
|
||||||
|
desc := canvas.NewText("Choose how to authenticate", colorPlaceholder)
|
||||||
|
desc.TextSize = 15
|
||||||
|
|
||||||
|
methodForm := widget.NewForm(
|
||||||
|
widget.NewFormItem("Method", w.authMethod),
|
||||||
|
)
|
||||||
|
|
||||||
|
tokenForm := widget.NewForm(
|
||||||
|
widget.NewFormItem("Token", w.authToken),
|
||||||
|
)
|
||||||
|
|
||||||
|
credForm := widget.NewForm(
|
||||||
|
widget.NewFormItem("Username", w.authUser),
|
||||||
|
widget.NewFormItem("Password", w.authPass),
|
||||||
|
widget.NewFormItem("Client Name", w.clientName),
|
||||||
|
)
|
||||||
|
|
||||||
|
authContent := container.NewStack(tokenForm, credForm)
|
||||||
|
w.authMethod.OnChanged(w.authMethod.Selected)
|
||||||
|
|
||||||
|
backBtn := widget.NewButton("Back", func() {
|
||||||
|
w.showStep(stepServer)
|
||||||
|
})
|
||||||
|
nextBtn := widget.NewButton("Connect", func() {
|
||||||
|
if w.authMethod.Selected == "Token" && w.authToken.Text == "" {
|
||||||
|
w.app.showError("Token is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.authMethod.Selected == "Username/Password" {
|
||||||
|
if w.authUser.Text == "" || w.authPass.Text == "" {
|
||||||
|
w.app.showError("Username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.showStep(stepFinish)
|
||||||
|
})
|
||||||
|
nextBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
return container.NewVBox(
|
||||||
|
title,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
desc,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
methodForm,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
authContent,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
container.NewHBox(backBtn, layout.NewSpacer(), nextBtn),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wizard) finishPage() fyne.CanvasObject {
|
||||||
|
title := canvas.NewText("Setup Complete!", colorForeground)
|
||||||
|
title.TextSize = 32
|
||||||
|
title.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
title.Alignment = fyne.TextAlignCenter
|
||||||
|
|
||||||
|
checkIcon := canvas.NewText("\u2713", colorSuccess)
|
||||||
|
checkIcon.TextSize = 64
|
||||||
|
checkIcon.Alignment = fyne.TextAlignCenter
|
||||||
|
|
||||||
|
summary := fmt.Sprintf(`**Server:** %s:%s
|
||||||
|
**Auth:** %s
|
||||||
|
**Client:** %s`,
|
||||||
|
w.serverAddr.Text, w.serverPort.Text,
|
||||||
|
w.authMethod.Selected,
|
||||||
|
func() string {
|
||||||
|
if w.authMethod.Selected == "Token" {
|
||||||
|
return "Token auth"
|
||||||
|
}
|
||||||
|
if w.clientName.Text != "" {
|
||||||
|
return w.clientName.Text
|
||||||
|
}
|
||||||
|
return w.authUser.Text
|
||||||
|
}(),
|
||||||
|
)
|
||||||
|
|
||||||
|
summaryText := widget.NewRichTextFromMarkdown(summary)
|
||||||
|
|
||||||
|
backBtn := widget.NewButton("Back", func() {
|
||||||
|
w.showStep(stepAuth)
|
||||||
|
})
|
||||||
|
finishBtn := widget.NewButton("Launch Dashboard", func() {
|
||||||
|
if w.onComplete != nil {
|
||||||
|
w.onComplete(WizardResult{
|
||||||
|
ServerAddr: w.serverAddr.Text,
|
||||||
|
ServerPort: w.serverPort.Text,
|
||||||
|
AuthMethod: w.authMethod.Selected,
|
||||||
|
Token: w.authToken.Text,
|
||||||
|
Username: w.authUser.Text,
|
||||||
|
Password: w.authPass.Text,
|
||||||
|
ClientName: w.clientName.Text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
finishBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
return container.NewVBox(
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
container.NewHBox(layout.NewSpacer(), checkIcon, layout.NewSpacer()),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
container.NewHBox(layout.NewSpacer(), title, layout.NewSpacer()),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
summaryText,
|
||||||
|
widget.NewLabel(""),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
container.NewHBox(backBtn, layout.NewSpacer(), finishBtn),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wizard) Content() *fyne.Container {
|
||||||
|
return w.container
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !kanholec_gui
|
||||||
|
|
||||||
// Copyright 2016 fatedier, fatedier@gmail.com
|
// Copyright 2016 fatedier, fatedier@gmail.com
|
||||||
//
|
//
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build kanholec_gui
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"kanhole/client/gui"
|
||||||
|
"kanhole/pkg/util/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
system.EnableCompatibilityMode()
|
||||||
|
gui.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Build kanholec MSI installer using WiX Toolset
|
||||||
|
.DESCRIPTION
|
||||||
|
Builds the kanholec binary (with GUI support) and packages it into
|
||||||
|
an MSI installer using WiX v7.
|
||||||
|
.PARAMETER Arch
|
||||||
|
Target architecture: amd64 (default) or arm64
|
||||||
|
.PARAMETER SkipBuild
|
||||||
|
Skip building the Go binary (use existing one)
|
||||||
|
.PARAMETER OutputDir
|
||||||
|
Output directory for the MSI (default: bin/)
|
||||||
|
.EXAMPLE
|
||||||
|
.\build-msi.ps1
|
||||||
|
.\build-msi.ps1 -Arch arm64
|
||||||
|
.\build-msi.ps1 -SkipBuild
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[ValidateSet("amd64", "arm64")]
|
||||||
|
[string]$Arch = "amd64",
|
||||||
|
[switch]$SkipBuild,
|
||||||
|
[string]$OutputDir = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$scriptDir = Split-Path -Parent $PSCommandPath
|
||||||
|
$rootDir = Resolve-Path (Join-Path $scriptDir "..\..")
|
||||||
|
|
||||||
|
if ($OutputDir -eq "") {
|
||||||
|
$OutputDir = Join-Path $rootDir "bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = "0.69.0"
|
||||||
|
$binaryName = "kanholec-windows-$Arch.exe"
|
||||||
|
$binaryPath = Join-Path $OutputDir $binaryName
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " kanholec MSI Builder (WiX v7)" -ForegroundColor Cyan
|
||||||
|
Write-Host " Version: $version | Arch: $Arch" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Check WiX
|
||||||
|
try {
|
||||||
|
$wixVersion = & wix --version 2>&1
|
||||||
|
Write-Host " WiX: $wixVersion" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host " ERROR: WiX Toolset not found. Install with:" -ForegroundColor Red
|
||||||
|
Write-Host " dotnet tool install --global wix" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check Go
|
||||||
|
try {
|
||||||
|
$goVersion = & go version 2>&1
|
||||||
|
Write-Host " Go: $goVersion" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host " ERROR: Go not found." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build binary
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Building kanholec (GUI + CGO)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$env:CGO_ENABLED = "1"
|
||||||
|
$env:GOOS = "windows"
|
||||||
|
$env:GOARCH = $Arch
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||||
|
|
||||||
|
$ldflags = "-s -w -H windowsgui"
|
||||||
|
$tags = "kanholec,kanholec_gui"
|
||||||
|
|
||||||
|
& go build -trimpath -ldflags $ldflags -tags $tags -o $binaryPath ./cmd/kanholec
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " ERROR: Go build failed" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:CGO_ENABLED = ""
|
||||||
|
$env:GOOS = ""
|
||||||
|
$env:GOARCH = ""
|
||||||
|
|
||||||
|
Write-Host " Binary: $binaryPath" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
if (-not (Test-Path $binaryPath)) {
|
||||||
|
Write-Host " ERROR: Binary not found at $binaryPath" -ForegroundColor Red
|
||||||
|
Write-Host " Build it first or remove -SkipBuild flag" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " Using existing binary: $binaryPath" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create icon if missing
|
||||||
|
$iconPath = Join-Path $scriptDir "kanholec.ico"
|
||||||
|
if (-not (Test-Path $iconPath)) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Creating placeholder icon..." -ForegroundColor Yellow
|
||||||
|
# Create a minimal valid .ico file (16x16 32-bit)
|
||||||
|
$ico = New-Object byte[] 318
|
||||||
|
# ICO header
|
||||||
|
$ico[0] = 0; $ico[1] = 0 # reserved
|
||||||
|
$ico[2] = 1; $ico[3] = 0 # type: icon
|
||||||
|
$ico[4] = 1; $ico[5] = 0 # count: 1
|
||||||
|
# Directory entry
|
||||||
|
$ico[6] = 16 # width
|
||||||
|
$ico[7] = 16 # height
|
||||||
|
$ico[8] = 0 # colors
|
||||||
|
$ico[9] = 0 # reserved
|
||||||
|
$ico[10] = 1; $ico[11] = 0 # planes
|
||||||
|
$ico[12] = 32; $ico[13] = 0 # bpp
|
||||||
|
$ico[14] = 0; $ico[15] = 1; $ico[16] = 0; $ico[17] = 0 # size = 256
|
||||||
|
$ico[18] = 22; $ico[19] = 0; $ico[20] = 0; $ico[21] = 0 # offset = 22
|
||||||
|
# BMP header (40 bytes)
|
||||||
|
$ico[22] = 40; $ico[23] = 0; $ico[24] = 0; $ico[25] = 0 # header size
|
||||||
|
$ico[26] = 16; $ico[27] = 0; $ico[28] = 0; $ico[29] = 0 # width
|
||||||
|
$ico[30] = 32; $ico[31] = 0; $ico[32] = 0; $ico[33] = 0 # height (2x for icon)
|
||||||
|
$ico[34] = 1; $ico[35] = 0 # planes
|
||||||
|
$ico[36] = 32; $ico[37] = 0 # bpp
|
||||||
|
# Fill pixel data with blue-ish color (kanhole brand)
|
||||||
|
for ($i = 62; $i -lt 318; $i += 4) {
|
||||||
|
$ico[$i] = 236 # B
|
||||||
|
$ico[$i+1] = 152 # G
|
||||||
|
$ico[$i+2] = 56 # R
|
||||||
|
$ico[$i+3] = 255 # A
|
||||||
|
}
|
||||||
|
[System.IO.File]::WriteAllBytes($iconPath, $ico)
|
||||||
|
Write-Host " Icon: $iconPath (placeholder)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build MSI
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Building MSI with WiX..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$wixprojPath = Join-Path $scriptDir "kanholec.wixproj"
|
||||||
|
$msiOutput = Join-Path $OutputDir "kanholec-$version-$Arch.msi"
|
||||||
|
|
||||||
|
Push-Location $rootDir
|
||||||
|
try {
|
||||||
|
& dotnet build $wixprojPath -c Release -p:Platform=x64 -o $OutputDir
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " ERROR: WiX build failed" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$builtMsi = Get-ChildItem -Path $OutputDir -Filter "*.msi" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
if ($builtMsi) {
|
||||||
|
if ($builtMsi.FullName -ne $msiOutput) {
|
||||||
|
Move-Item -Path $builtMsi.FullName -Destination $msiOutput -Force
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " MSI: $msiOutput" -ForegroundColor Green
|
||||||
|
Write-Host " Size: $([math]::Round((Get-Item $msiOutput).Length / 1KB, 1)) KB" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Done!" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 318 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="WixToolset.Sdk/7.0.0">
|
||||||
|
<PropertyGroup>
|
||||||
|
<InstallerPlatform>x64</InstallerPlatform>
|
||||||
|
<OutputName>kanholec-0.69.0</OutputName>
|
||||||
|
<OutputType>Package</OutputType>
|
||||||
|
<DefineConstants>Version=0.69.0</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="WixToolset.UI.wixext" Version="7.0.0" />
|
||||||
|
<PackageReference Include="WixToolset.Util.wixext" Version="7.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
+124
-39
@@ -1,47 +1,132 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||||
<Wix xmlns="http://wixtoolset.org/schemas/v3/wxs">
|
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||||
<Product
|
|
||||||
Name="kanholec"
|
|
||||||
Id="*"
|
|
||||||
UpgradeCode="A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
|
|
||||||
Language="1033"
|
|
||||||
Codepage="1252"
|
|
||||||
Version="0.62.0"
|
|
||||||
Manufacturer="kanhole Contributors">
|
|
||||||
|
|
||||||
<Package
|
<Package
|
||||||
InstallerVersion="200"
|
Name="kanholec"
|
||||||
Compressed="yes"
|
Manufacturer="kanhole Contributors"
|
||||||
InstallScope="perMachine"
|
Version="0.69.0"
|
||||||
Description="kanhole Client"
|
UpgradeCode="A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
|
||||||
Comments="kanholec is the client component of kanhole - a fast reverse proxy." />
|
Scope="perMachine">
|
||||||
|
|
||||||
<Media Id="1" Cabinet="kanholec.cab" EmbedCab="yes" />
|
<MajorUpgrade DowngradeErrorMessage="A newer version of kanholec is already installed." />
|
||||||
|
|
||||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
<MediaTemplate EmbedCab="yes" />
|
||||||
<Directory Id="ProgramFiles64Folder">
|
|
||||||
<Directory Id="INSTALLDIR" Name="kanholec">
|
|
||||||
<Component Id="FrpcBinary" Guid="E1B2C3D4-E5F6-7890-ABCD-EF1234567894">
|
|
||||||
<File Id="FrpcExe" Name="kanholec.exe" DiskId="1" Source="bin/kanholec-windows-amd64.exe" KeyPath="yes" />
|
|
||||||
</Component>
|
|
||||||
</Directory>
|
|
||||||
</Directory>
|
|
||||||
|
|
||||||
<Directory Id="ProgramMenuFolder">
|
<Icon Id="kanholecIcon" SourceFile="kanholec.ico" />
|
||||||
<Directory Id="ApplicationProgramsFolder" Name="kanholec">
|
<Property Id="ARPPRODUCTICON" Value="kanholecIcon" />
|
||||||
<Component Id="StartMenuShortcuts" Guid="A2B2C3D4-E5F6-7890-ABCD-EF1234567892">
|
<Property Id="ARPHELPLINK" Value="https://github.com/kanhole/kanhole" />
|
||||||
<Shortcut Id="FrpcShortcut" Name="kanholec" Target="[INSTALLDIR]kanholec.exe" WorkingDirectory="INSTALLDIR" Description="kanhole client" />
|
<Property Id="ARPURLINFOABOUT" Value="https://github.com/kanhole/kanhole" />
|
||||||
<Shortcut Id="UninstallShortcut" Name="Uninstall kanholec" Target="[System64Folder]msiexec.exe" Arguments="/x [ProductCode]" />
|
|
||||||
<RemoveFolder Id="RemoveApplicationProgramsFolder" On="uninstall" />
|
|
||||||
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="installed" Type="integer" Value="1" />
|
|
||||||
</Component>
|
|
||||||
</Directory>
|
|
||||||
</Directory>
|
|
||||||
</Directory>
|
|
||||||
|
|
||||||
<Feature Id="Main" Title="kanholec" Description="kanhole client binary" Level="1">
|
<Feature Id="Main" Title="kanholec" Description="kanhole reverse proxy client" Level="1"
|
||||||
<ComponentRef Id="FrpcBinary" />
|
AllowAdvertise="no">
|
||||||
<ComponentRef Id="StartMenuShortcuts" />
|
<ComponentGroupRef Id="BinaryFiles" />
|
||||||
|
<ComponentGroupRef Id="ConfigFiles" />
|
||||||
|
<ComponentGroupRef Id="Shortcuts" />
|
||||||
|
<ComponentRef Id="ServiceComponent" />
|
||||||
</Feature>
|
</Feature>
|
||||||
</Product>
|
|
||||||
|
<Feature Id="DesktopShortcutFeature" Title="Desktop Shortcut" Description="Create a desktop shortcut"
|
||||||
|
Level="1000" AllowAdvertise="no">
|
||||||
|
<ComponentRef Id="DesktopShortcutComponent" />
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<Feature Id="PathFeature" Title="Add to PATH" Description="Add kanholec to system PATH"
|
||||||
|
Level="1" AllowAdvertise="no">
|
||||||
|
<ComponentRef Id="PathComponent" />
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<ui:WixUI
|
||||||
|
Id="WixUI_FeatureTree"
|
||||||
|
InstallDirectory="INSTALLFOLDER" />
|
||||||
|
|
||||||
|
<WixVariable Id="WixUILicenseRtf" Value="license.rtf" />
|
||||||
|
|
||||||
|
<StandardDirectory Id="ProgramFiles6432Folder">
|
||||||
|
<Directory Id="INSTALLFOLDER" Name="kanholec" />
|
||||||
|
</StandardDirectory>
|
||||||
|
|
||||||
|
<StandardDirectory Id="ProgramMenuFolder">
|
||||||
|
<Directory Id="ApplicationProgramsFolder" Name="kanholec" />
|
||||||
|
</StandardDirectory>
|
||||||
|
|
||||||
|
<StandardDirectory Id="CommonAppDataFolder">
|
||||||
|
<Directory Id="ConfigFolder" Name="kanholec" />
|
||||||
|
</StandardDirectory>
|
||||||
|
|
||||||
|
<ComponentGroup Id="BinaryFiles" Directory="INSTALLFOLDER">
|
||||||
|
<Component Id="KanholeCBinary" Guid="E1B2C3D4-E5F6-7890-ABCD-EF1234567894">
|
||||||
|
<File Id="kanholecExe"
|
||||||
|
Name="kanholec.exe"
|
||||||
|
Source="..\..\bin\kanholec-windows-amd64.exe"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</ComponentGroup>
|
||||||
|
|
||||||
|
<ComponentGroup Id="ConfigFiles" Directory="ConfigFolder">
|
||||||
|
<Component Id="DefaultConfig" Guid="F1B2C3D4-E5F6-7890-ABCD-EF1234567895" NeverOverwrite="yes">
|
||||||
|
<File Id="kanholecToml"
|
||||||
|
Name="kanholec.toml"
|
||||||
|
Source="kanholec.default.toml"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</ComponentGroup>
|
||||||
|
|
||||||
|
<ComponentGroup Id="Shortcuts" Directory="ApplicationProgramsFolder">
|
||||||
|
<Component Id="StartMenuShortcuts" Guid="A2B2C3D4-E5F6-7890-ABCD-EF1234567892">
|
||||||
|
<Shortcut Id="StartMenuShortcut"
|
||||||
|
Name="kanholec"
|
||||||
|
Target="[INSTALLFOLDER]kanholec.exe"
|
||||||
|
WorkingDirectory="INSTALLFOLDER"
|
||||||
|
Description="kanhole reverse proxy client"
|
||||||
|
Icon="kanholecIcon" />
|
||||||
|
<Shortcut Id="ConfigFolderShortcut"
|
||||||
|
Name="Config Directory"
|
||||||
|
Target="[ConfigFolder]"
|
||||||
|
Description="Open kanholec config directory" />
|
||||||
|
<Shortcut Id="UninstallShortcut"
|
||||||
|
Name="Uninstall kanholec"
|
||||||
|
Target="[System64Folder]msiexec.exe"
|
||||||
|
Arguments="/x [ProductCode]"
|
||||||
|
Description="Uninstall kanholec" />
|
||||||
|
<RemoveFolder Id="RemoveApplicationProgramsFolder" On="uninstall" />
|
||||||
|
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="installed" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</ComponentGroup>
|
||||||
|
|
||||||
|
<Component Id="DesktopShortcutComponent" Directory="DesktopFolder" Guid="B2B2C3D4-E5F6-7890-ABCD-EF1234567893">
|
||||||
|
<Shortcut Id="DesktopShortcut"
|
||||||
|
Name="kanholec"
|
||||||
|
Target="[INSTALLFOLDER]kanholec.exe"
|
||||||
|
WorkingDirectory="INSTALLFOLDER"
|
||||||
|
Description="kanhole reverse proxy client"
|
||||||
|
Icon="kanholecIcon" />
|
||||||
|
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="desktop_shortcut" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
|
||||||
|
<Component Id="PathComponent" Directory="INSTALLFOLDER" Guid="C2B2C3D4-E5F6-7890-ABCD-EF1234567896">
|
||||||
|
<Environment Id="PATH" Name="PATH" Value="[INSTALLFOLDER]" Permanent="no" Part="last" Action="set" System="yes" />
|
||||||
|
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="path_added" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
|
||||||
|
<Component Id="ServiceComponent" Directory="INSTALLFOLDER" Guid="D2B2C3D4-E5F6-7890-ABCD-EF1234567897">
|
||||||
|
<RegistryValue Root="HKCU" Key="Software\kanhole\kanholec" Name="service_registered" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
<ServiceInstall
|
||||||
|
Id="kanholecService"
|
||||||
|
Name="kanholec"
|
||||||
|
DisplayName="kanholec - kanhole Client"
|
||||||
|
Description="kanhole reverse proxy client service"
|
||||||
|
Start="demand"
|
||||||
|
Type="ownProcess"
|
||||||
|
ErrorControl="ignore"
|
||||||
|
Arguments='-c "[ConfigFolder]kanholec.toml"'
|
||||||
|
Account="LocalSystem" />
|
||||||
|
<ServiceControl
|
||||||
|
Id="kanholecServiceControl"
|
||||||
|
Name="kanholec"
|
||||||
|
Stop="both"
|
||||||
|
Remove="uninstall"
|
||||||
|
Wait="no" />
|
||||||
|
</Component>
|
||||||
|
|
||||||
|
</Package>
|
||||||
</Wix>
|
</Wix>
|
||||||
|
|||||||
Reference in New Issue
Block a user