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:
|
||||
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
|
||||
wixl -o bin/kanholec-$(KANHOLE_VERSION).msi packaging/windows/kanholec.wxs 2>/dev/null \
|
||||
|| { 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; }
|
||||
kanholec-windows-gui:
|
||||
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
|
||||
|
||||
.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
|
||||
|
||||
|
||||
+504
-177
@@ -3,11 +3,17 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
@@ -17,230 +23,551 @@ import (
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"kanhole/client"
|
||||
"kanhole/pkg/config"
|
||||
"kanhole/pkg/config/source"
|
||||
v1 "kanhole/pkg/config/v1"
|
||||
"kanhole/pkg/policy/security"
|
||||
"kanhole/pkg/util/log"
|
||||
"kanhole/pkg/util/version"
|
||||
)
|
||||
|
||||
type AppState int
|
||||
|
||||
const (
|
||||
StateSetup AppState = iota
|
||||
StateDashboard
|
||||
)
|
||||
|
||||
type App struct {
|
||||
fyneApp fyne.App
|
||||
window fyne.Window
|
||||
fyneApp fyne.App
|
||||
window fyne.Window
|
||||
wizard *Wizard
|
||||
dashboard *Dashboard
|
||||
tray *TrayManager
|
||||
svcMgr *ServiceManager
|
||||
|
||||
serverURL *widget.Entry
|
||||
authToken *widget.Entry
|
||||
clientKey *widget.Entry
|
||||
statusLabel *widget.Label
|
||||
configView *widget.Entry
|
||||
proxyList *widget.List
|
||||
startBtn *widget.Button
|
||||
stopBtn *widget.Button
|
||||
connectBtn *widget.Button
|
||||
mainContainer *fyne.Container
|
||||
state AppState
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
configData string
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
configData string
|
||||
clientService *client.Service
|
||||
cancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
func New() *App {
|
||||
a := &App{}
|
||||
a.fyneApp = app.New()
|
||||
a.window = a.fyneApp.NewWindow("kanholec GUI")
|
||||
a.window.Resize(fyne.NewSize(850, 620))
|
||||
a.fyneApp = app.NewWithID("io.kanhole.kanholec")
|
||||
a.fyneApp.Settings().SetTheme(&kanholeTheme{})
|
||||
|
||||
a.window = a.fyneApp.NewWindow("kanholec")
|
||||
a.window.Resize(fyne.NewSize(900, 640))
|
||||
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
|
||||
}
|
||||
|
||||
func (a *App) saveSettings() {
|
||||
p := a.fyneApp.Preferences()
|
||||
p.SetString("server_url", a.serverURL.Text)
|
||||
func (a *App) hasSavedConfig() bool {
|
||||
prefs := a.fyneApp.Preferences()
|
||||
return prefs.StringWithFallback("server_addr", "") != ""
|
||||
}
|
||||
|
||||
func (a *App) loadSettings() {
|
||||
p := a.fyneApp.Preferences()
|
||||
if url := p.StringWithFallback("server_url", "http://localhost:7500"); url != "" {
|
||||
a.serverURL.SetText(url)
|
||||
}
|
||||
func (a *App) showWizard() {
|
||||
a.state = StateSetup
|
||||
a.wizard = NewWizard(a, a.onWizardComplete)
|
||||
a.mainContainer.Objects = []fyne.CanvasObject{a.wizard.Content()}
|
||||
a.mainContainer.Refresh()
|
||||
}
|
||||
|
||||
func (a *App) setupUI() {
|
||||
a.serverURL = widget.NewEntry()
|
||||
a.serverURL.SetText("http://localhost:7500")
|
||||
a.serverURL.PlaceHolder = "http://frps-server:7500"
|
||||
func (a *App) showDashboard() {
|
||||
a.state = StateDashboard
|
||||
a.dashboard = NewDashboard(a)
|
||||
|
||||
a.authToken = widget.NewEntry()
|
||||
a.authToken.PlaceHolder = "One-time token"
|
||||
prefs := a.fyneApp.Preferences()
|
||||
addr := prefs.StringWithFallback("server_addr", "127.0.0.1")
|
||||
port := prefs.IntWithFallback("server_port", 7000)
|
||||
a.dashboard.SetServerInfo(addr, port)
|
||||
|
||||
a.clientKey = widget.NewEntry()
|
||||
a.clientKey.PlaceHolder = "Client key (from server)"
|
||||
toolbar := a.buildToolbar()
|
||||
|
||||
a.statusLabel = widget.NewLabel("Not connected")
|
||||
a.statusLabel.Importance = widget.MediumImportance
|
||||
|
||||
a.configView = widget.NewMultiLineEntry()
|
||||
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.startBtn = widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), a.onStart)
|
||||
a.startBtn.Disable()
|
||||
a.stopBtn = widget.NewButtonWithIcon("Stop", theme.MediaStopIcon(), a.onStop)
|
||||
a.stopBtn.Disable()
|
||||
|
||||
// Settings form
|
||||
settingsForm := widget.NewForm(
|
||||
widget.NewFormItem("Server URL", a.serverURL),
|
||||
)
|
||||
settingsForm.OnSubmit = func() { a.saveSettings() }
|
||||
settingsBtn := widget.NewButtonWithIcon("Save Settings", theme.DocumentSaveIcon(), a.saveSettings)
|
||||
|
||||
settingsTab := container.NewVBox(
|
||||
widget.NewLabelWithStyle("Connection Settings", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsForm,
|
||||
settingsBtn,
|
||||
layout.NewSpacer(),
|
||||
)
|
||||
|
||||
// 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,
|
||||
),
|
||||
)
|
||||
|
||||
// Config tab
|
||||
configTab := container.NewBorder(
|
||||
widget.NewLabelWithStyle("Configuration (read-only)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
content := container.NewBorder(
|
||||
toolbar,
|
||||
nil, nil, nil,
|
||||
a.configView,
|
||||
a.dashboard.Content(),
|
||||
)
|
||||
|
||||
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.mainContainer.Objects = []fyne.CanvasObject{content}
|
||||
a.mainContainer.Refresh()
|
||||
|
||||
a.window.SetContent(tabs)
|
||||
a.loadSettings()
|
||||
a.dashboard.AppendLog("Dashboard initialized")
|
||||
a.dashboard.AppendLog(fmt.Sprintf("Server: %s:%d", addr, port))
|
||||
|
||||
a.tray.Start()
|
||||
}
|
||||
|
||||
func (a *App) onConnect() {
|
||||
url := strings.TrimRight(a.serverURL.Text, "/")
|
||||
token := a.authToken.Text
|
||||
key := a.clientKey.Text
|
||||
a.saveSettings()
|
||||
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
|
||||
|
||||
if token == "" && key == "" {
|
||||
a.statusLabel.SetText("Error: provide token or client key")
|
||||
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,
|
||||
widget.NewLabel(""),
|
||||
resetBtn,
|
||||
),
|
||||
widget.NewLabel(""),
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabel(""),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) onWizardComplete(result WizardResult) {
|
||||
prefs := a.fyneApp.Preferences()
|
||||
prefs.SetString("server_addr", result.ServerAddr)
|
||||
|
||||
port, _ := strconv.Atoi(result.ServerPort)
|
||||
if port == 0 {
|
||||
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 err != nil {
|
||||
a.dashboard.AppendLog(fmt.Sprintf("Auth error: %v", err))
|
||||
a.dashboard.AppendLog("You can still start the client with manual configuration.")
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
a.statusLabel.SetText(fmt.Sprintf("Auth failed (%d): %s", resp.StatusCode, string(respBody)))
|
||||
return
|
||||
}
|
||||
|
||||
configData, _ := io.ReadAll(resp.Body)
|
||||
if configData != nil {
|
||||
a.configData = string(configData)
|
||||
a.configView.SetText(a.configData)
|
||||
a.statusLabel.SetText("Connected (token auth)")
|
||||
a.startBtn.Enable()
|
||||
a.connectBtn.SetText("Reconnect")
|
||||
} else if key != "" {
|
||||
apiURL := fmt.Sprintf("%s/admin/api/frpc/proxy-config/%s", url, key)
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
a.statusLabel.SetText(fmt.Sprintf("Config fetch failed (%d)", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
configData, _ := io.ReadAll(resp.Body)
|
||||
a.configData = string(configData)
|
||||
a.configView.SetText(a.configData)
|
||||
a.statusLabel.SetText("Connected (key auth)")
|
||||
a.startBtn.Enable()
|
||||
a.connectBtn.SetText("Reconnect")
|
||||
a.saveConfigToFile(configData)
|
||||
a.dashboard.SetConfig(a.configData)
|
||||
a.dashboard.AppendLog("Configuration fetched successfully")
|
||||
a.dashboard.AppendLog("Configuration saved to disk")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) onStart() {
|
||||
if a.configData == "" {
|
||||
a.statusLabel.SetText("Error: no config loaded")
|
||||
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()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
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) authWithCredentials(serverURL, username, password, clientName string) ([]byte, error) {
|
||||
apiURL := serverURL + "/admin/api/client/auth"
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"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 {
|
||||
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()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
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
|
||||
}
|
||||
|
||||
a.dashboard.AppendLog("Starting client...")
|
||||
|
||||
var cfgData []byte
|
||||
var err error
|
||||
|
||||
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
|
||||
}
|
||||
a.dashboard.AppendLog("Configuration loaded from disk")
|
||||
}
|
||||
|
||||
var cfg v1.ClientConfig
|
||||
if err := config.LoadConfigure([]byte(a.configData), &cfg, false, "toml"); err != nil {
|
||||
dialog.ShowError(fmt.Errorf("invalid config: %w", err), a.window)
|
||||
if err := config.LoadConfigure(cfgData, &cfg, false, "toml"); err != nil {
|
||||
a.dashboard.AppendLog(fmt.Sprintf("Invalid configuration: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.running = true
|
||||
a.mu.Unlock()
|
||||
result := &config.ClientConfigLoadResult{
|
||||
Common: &cfg.ClientCommonConfig,
|
||||
Proxies: make([]v1.ProxyConfigurer, 0),
|
||||
}
|
||||
for _, c := range cfg.Proxies {
|
||||
result.Proxies = append(result.Proxies, c.ProxyConfigurer)
|
||||
}
|
||||
|
||||
a.startBtn.Disable()
|
||||
a.stopBtn.Enable()
|
||||
a.statusLabel.SetText("Running")
|
||||
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.running = false
|
||||
a.mu.Unlock()
|
||||
a.dashboard.SetRunning(false)
|
||||
a.dashboard.AppendLog("Client stopped")
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) onStop() {
|
||||
func (a *App) stopClient() {
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.startBtn.Enable()
|
||||
a.stopBtn.Disable()
|
||||
a.statusLabel.SetText("Stopped")
|
||||
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() {
|
||||
@@ -249,6 +576,6 @@ func (a *App) Run() {
|
||||
|
||||
func Run() {
|
||||
gui := New()
|
||||
log.Infof("starting frpc GUI")
|
||||
log.Infof("starting kanholec GUI v%s", version.Full())
|
||||
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
|
||||
//
|
||||
// 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/v3/wxs">
|
||||
<Product
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
|
||||
<Package
|
||||
Name="kanholec"
|
||||
Id="*"
|
||||
Manufacturer="kanhole Contributors"
|
||||
Version="0.69.0"
|
||||
UpgradeCode="A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
|
||||
Language="1033"
|
||||
Codepage="1252"
|
||||
Version="0.62.0"
|
||||
Manufacturer="kanhole Contributors">
|
||||
Scope="perMachine">
|
||||
|
||||
<Package
|
||||
InstallerVersion="200"
|
||||
Compressed="yes"
|
||||
InstallScope="perMachine"
|
||||
Description="kanhole Client"
|
||||
Comments="kanholec is the client component of kanhole - a fast reverse proxy." />
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of kanholec is already installed." />
|
||||
|
||||
<Media Id="1" Cabinet="kanholec.cab" EmbedCab="yes" />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||
<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>
|
||||
<Icon Id="kanholecIcon" SourceFile="kanholec.ico" />
|
||||
<Property Id="ARPPRODUCTICON" Value="kanholecIcon" />
|
||||
<Property Id="ARPHELPLINK" Value="https://github.com/kanhole/kanhole" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://github.com/kanhole/kanhole" />
|
||||
|
||||
<Directory Id="ProgramMenuFolder">
|
||||
<Directory Id="ApplicationProgramsFolder" Name="kanholec">
|
||||
<Component Id="StartMenuShortcuts" Guid="A2B2C3D4-E5F6-7890-ABCD-EF1234567892">
|
||||
<Shortcut Id="FrpcShortcut" Name="kanholec" Target="[INSTALLDIR]kanholec.exe" WorkingDirectory="INSTALLDIR" Description="kanhole client" />
|
||||
<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">
|
||||
<ComponentRef Id="FrpcBinary" />
|
||||
<ComponentRef Id="StartMenuShortcuts" />
|
||||
<Feature Id="Main" Title="kanholec" Description="kanhole reverse proxy client" Level="1"
|
||||
AllowAdvertise="no">
|
||||
<ComponentGroupRef Id="BinaryFiles" />
|
||||
<ComponentGroupRef Id="ConfigFiles" />
|
||||
<ComponentGroupRef Id="Shortcuts" />
|
||||
<ComponentRef Id="ServiceComponent" />
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user