Files
tailscale-custom/cmd/tailscale-tray/main.go
T

937 lines
24 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// tailscale-tray is a Windows system tray application for Tailscale-Custom.
package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
"fyne.io/systray"
"golang.org/x/sys/windows"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
)
// app holds the systray menu state and IPN client.
type app struct {
lc *local.Client
mu sync.Mutex
status *ipnstate.Status
curProfile ipn.LoginProfile
allProfiles []ipn.LoginProfile
prevState string // track state transitions
inAction int32 // atomic flag to prevent re-entrant actions
bgCtx context.Context
bgCancel context.CancelFunc
rebuildCh chan struct{}
eventCancel context.CancelFunc
}
func main() {
// Single instance mutex. Lock the OS thread so that CreateMutex and
// the subsequent error check reference the same thread-local LastError.
// (Go may otherwise reschedule the goroutine onto a different OS thread
// between the two syscalls, yielding a meaningless LastError value.)
runtime.LockOSThread()
mutexName, _ := windows.UTF16PtrFromString("Global\\Tailscale-Custom-Tray-Mutex")
handle, createErr := windows.CreateMutex(nil, false, mutexName)
lastErr := windows.GetLastError()
runtime.UnlockOSThread()
if handle == 0 {
// Real failure (access denied, invalid args, etc.)
log.Printf("CreateMutex failed: %v", createErr)
os.Exit(1)
}
if lastErr == windows.ERROR_ALREADY_EXISTS {
// Another instance is already running; exit silently with success.
windows.CloseHandle(handle)
os.Exit(0)
}
defer windows.CloseHandle(handle)
setupLogging()
log.Println("tailscale-tray starting")
// Top-level panic recovery so a stray panic in any goroutine that
// eventually reaches main doesn't just vanish the tray icon silently.
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in main: %v", r)
}
}()
a := &app{
lc: &local.Client{},
rebuildCh: make(chan struct{}, 1),
}
a.bgCtx, a.bgCancel = context.WithCancel(context.Background())
defer a.bgCancel()
a.updateState()
go a.watchIPNBus()
go a.periodicRefresh()
systray.Run(a.onReady, a.onExit)
}
// periodicRefresh acts as a safety-net against a silently-stuck IPN bus
// watcher (e.g. broken pipe on tailscaled restart that does not surface as
// an error on Next()). Every 15s it forces a fresh status snapshot and,
// if the backend state actually changed, triggers a menu rebuild.
func (a *app) periodicRefresh() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in periodicRefresh: %v", r)
}
}()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
var lastState string
for {
select {
case <-a.bgCtx.Done():
return
case <-ticker.C:
}
a.updateState()
a.mu.Lock()
cur := ""
if a.status != nil {
cur = a.status.BackendState
}
a.mu.Unlock()
if cur != lastState {
log.Printf("periodicRefresh: state changed %q -> %q, rebuilding", lastState, cur)
lastState = cur
a.triggerRebuild()
}
}
}
func setupLogging() {
dirs := []string{
filepath.Join(os.Getenv("ProgramData"), "Tailscale-Custom", "Logs"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Tailscale-Custom", "Logs"),
}
for _, dir := range dirs {
os.MkdirAll(dir, 0700)
f, err := os.OpenFile(filepath.Join(dir, "tray.log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err == nil {
log.SetOutput(f)
return
}
}
}
func (a *app) updateState() {
a.mu.Lock()
defer a.mu.Unlock()
ctx, cancel := context.WithTimeout(a.bgCtx, 5*time.Second)
defer cancel()
var err error
a.status, err = a.lc.Status(ctx)
if err != nil {
log.Printf("updateState: Status error: %v", err)
a.status = nil
}
a.curProfile, a.allProfiles, err = a.lc.ProfileStatus(ctx)
if err != nil {
log.Printf("updateState: ProfileStatus error: %v", err)
}
}
func (a *app) onReady() {
log.Println("onReady")
systray.SetIcon(iconDisconnected)
systray.SetTooltip("Tailscale-Custom")
a.rebuild()
}
func (a *app) onExit() {
log.Println("onExit")
a.bgCancel()
}
// onClick registers a per-item click handler goroutine (official Tailscale pattern).
func onClick(ctx context.Context, item *systray.MenuItem, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in onClick: %v", r)
}
}()
for {
select {
case <-ctx.Done():
return
case <-item.ClickedCh:
fn()
}
}
}()
}
func (a *app) rebuild() {
a.mu.Lock()
defer a.mu.Unlock()
log.Println("rebuild: start")
if a.eventCancel != nil {
a.eventCancel()
}
ctx, cancelFn := context.WithCancel(a.bgCtx)
a.eventCancel = cancelFn
systray.ResetMenu()
state := a.backendState()
isRunning := state == "Running"
isNeedsLogin := state == "NeedsLogin"
// ── Header ──────────────────────────────────────────
a.setTrayIcon(state)
headerText := a.headerText(state)
header := systray.AddMenuItem(headerText, "")
header.Disable()
// Show IP on a separate line when connected
if isRunning && a.status != nil && a.status.Self != nil && len(a.status.Self.TailscaleIPs) > 0 {
selfIP := a.status.Self.TailscaleIPs[0].String()
ipLine := systray.AddMenuItem(" IP: "+selfIP, "Click to copy")
onClick(ctx, ipLine, func() {
copyToClipboard(selfIP)
})
}
// Show server info
if a.curProfile.ControlURL != "" {
server := strings.TrimPrefix(a.curProfile.ControlURL, "https://")
server = strings.TrimPrefix(server, "http://")
server = strings.TrimSuffix(server, "/")
serverLine := systray.AddMenuItem(" Server: "+server, "")
serverLine.Disable()
}
systray.AddSeparator()
// ── Connection Controls ─────────────────────────────
if isRunning {
disconnect := systray.AddMenuItem("Disconnect", "Disconnect from VPN")
onClick(ctx, disconnect, func() {
log.Println("action: Disconnect")
a.doEditPrefs(false)
})
} else if isNeedsLogin {
login := systray.AddMenuItem("Login", "Login to server")
onClick(ctx, login, func() {
log.Println("action: Login")
a.doLogin()
})
connect := systray.AddMenuItem("Connect", "Connect to VPN")
onClick(ctx, connect, func() {
log.Println("action: Connect")
a.doLogin()
})
} else {
// Stopped or unknown
connect := systray.AddMenuItem("Connect", "Connect to VPN")
onClick(ctx, connect, func() {
log.Println("action: Connect")
a.doEditPrefs(true)
})
}
systray.AddSeparator()
// ── Network Info (when connected) ───────────────────
if isRunning && a.status != nil {
peerCount := len(a.status.Peer)
onlineCount := 0
for _, p := range a.status.Peer {
if p.Online {
onlineCount++
}
}
netInfo := systray.AddMenuItem(fmt.Sprintf("Peers: %d online / %d total", onlineCount, peerCount), "")
netInfo.Disable()
// Show connected peers as submenu
if peerCount > 0 {
peersMenu := systray.AddMenuItem("Peer List", "")
for _, p := range a.status.Peer {
peerName := p.HostName
if peerName == "" {
peerName = p.DNSName
}
peerIP := ""
if len(p.TailscaleIPs) > 0 {
peerIP = p.TailscaleIPs[0].String()
}
statusMark := "-"
if p.Online {
statusMark = "+"
}
title := fmt.Sprintf("[%s] %s", statusMark, peerName)
if peerIP != "" {
title += " (" + peerIP + ")"
}
peerItem := peersMenu.AddSubMenuItem(title, "Click to copy IP")
ip := peerIP
onClick(ctx, peerItem, func() {
if ip != "" {
copyToClipboard(ip)
}
})
}
}
systray.AddSeparator()
}
// ── Account Management ──────────────────────────────
if len(a.allProfiles) > 0 {
accountLabel := a.curProfile.Name
if accountLabel == "" {
accountLabel = "Account"
}
accountMenu := systray.AddMenuItem(accountLabel, "")
time.Sleep(10 * time.Millisecond)
for _, profile := range a.allProfiles {
title := profileTitle(profile)
var item *systray.MenuItem
if profile.ID == a.curProfile.ID {
item = accountMenu.AddSubMenuItemCheckbox(" "+title, "", true)
} else {
item = accountMenu.AddSubMenuItem(" "+title, "")
}
pid := profile.ID
onClick(ctx, item, func() {
log.Printf("action: switch profile %v", pid)
opCtx, opCancel := context.WithTimeout(a.bgCtx, 10*time.Second)
defer opCancel()
if err := a.lc.SwitchProfile(opCtx, pid); err != nil {
log.Printf("SwitchProfile error: %v", err)
}
})
}
// Delete profile (only if more than 1)
if len(a.allProfiles) > 1 {
accountMenu.AddSubMenuItem("", "")
delProfile := accountMenu.AddSubMenuItem(" Remove current profile", "")
onClick(ctx, delProfile, func() {
log.Println("action: delete profile")
opCtx, opCancel := context.WithTimeout(a.bgCtx, 10*time.Second)
defer opCancel()
if err := a.lc.DeleteProfile(opCtx, a.curProfile.ID); err != nil {
log.Printf("DeleteProfile error: %v", err)
}
})
}
}
// Logout
logoutItem := systray.AddMenuItem("Logout", "Logout and deregister from server")
onClick(ctx, logoutItem, func() {
log.Println("action: Logout")
opCtx, opCancel := context.WithTimeout(a.bgCtx, 10*time.Second)
defer opCancel()
if err := a.lc.Logout(opCtx); err != nil {
log.Printf("Logout error: %v", err)
} else {
log.Println("Logout: OK")
}
})
systray.AddSeparator()
// ── Server Management ───────────────────────────────
addServerItem := systray.AddMenuItem("Add Server...", "Connect to a new control server")
onClick(ctx, addServerItem, func() {
log.Println("action: Add Server")
a.addServer()
})
systray.AddSeparator()
// ── Footer ──────────────────────────────────────────
quit := systray.AddMenuItem("Quit", "Quit Tailscale-Custom Tray")
onClick(ctx, quit, func() {
log.Println("action: Quit")
systray.Quit()
})
// Rebuild listener
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in rebuild listener: %v", r)
}
}()
select {
case <-ctx.Done():
return
case <-a.rebuildCh:
log.Println("rebuild triggered by IPNBus")
a.updateState()
a.rebuild()
}
}()
log.Printf("rebuild: done (state=%q, profiles=%d)", state, len(a.allProfiles))
}
// ── Helpers ─────────────────────────────────────────────
func (a *app) backendState() string {
if a.status == nil {
return ""
}
return a.status.BackendState
}
func (a *app) headerText(state string) string {
switch state {
case "Running":
if a.status.Self != nil {
return "Connected - " + a.status.Self.HostName
}
return "Connected"
case "NeedsLogin":
return "Login required"
case "Stopped":
return "Disconnected"
case "Starting":
return "Connecting..."
default:
if state == "" {
return "Service unavailable"
}
return state
}
}
func (a *app) setTrayIcon(state string) {
switch state {
case "Running":
systray.SetIcon(iconConnected)
systray.SetTooltip("Tailscale-Custom - Connected")
case "Starting":
systray.SetIcon(iconConnecting)
systray.SetTooltip("Tailscale-Custom - Connecting...")
default:
systray.SetIcon(iconDisconnected)
systray.SetTooltip("Tailscale-Custom - Disconnected")
}
}
func (a *app) doEditPrefs(wantRunning bool) {
opCtx, opCancel := context.WithTimeout(a.bgCtx, 10*time.Second)
defer opCancel()
_, err := a.lc.EditPrefs(opCtx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: wantRunning},
WantRunningSet: true,
})
if err != nil {
log.Printf("EditPrefs(wantRunning=%v) error: %v", wantRunning, err)
} else {
log.Printf("EditPrefs(wantRunning=%v): OK", wantRunning)
}
}
func (a *app) doLogin() {
if !atomic.CompareAndSwapInt32(&a.inAction, 0, 1) {
log.Println("doLogin: skipped (action in progress)")
return
}
defer atomic.StoreInt32(&a.inAction, 0)
// Take a thread-safe snapshot of the current profile URL.
a.mu.Lock()
serverURL := a.curProfile.ControlURL
a.mu.Unlock()
if serverURL == "" {
serverURL = "https://vpn.softs.business"
}
log.Printf("doLogin: server=%s", serverURL)
opCtx, opCancel := context.WithTimeout(a.bgCtx, 15*time.Second)
defer opCancel()
// Use Start() with UpdatePrefs like official CLI does
err := a.lc.Start(opCtx, ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: serverURL,
WantRunning: true,
},
})
if err != nil {
log.Printf("doLogin: Start error: %v", err)
} else {
log.Println("doLogin: Start OK")
}
// Then trigger interactive login to get BrowseToURL
if err := a.lc.StartLoginInteractive(opCtx); err != nil {
log.Printf("doLogin: StartLoginInteractive error: %v", err)
} else {
log.Println("doLogin: StartLoginInteractive OK")
}
// Poll for AuthURL and open browser
a.openAuthURL()
}
func (a *app) addServer() {
if !atomic.CompareAndSwapInt32(&a.inAction, 0, 1) {
log.Println("addServer: skipped (action in progress)")
return
}
defer atomic.StoreInt32(&a.inAction, 0)
serverURL := inputDialog("Add Server", "Enter the control server URL:")
if serverURL == "" {
log.Println("addServer: cancelled")
return
}
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
serverURL = "https://" + serverURL
}
log.Printf("addServer: url=%s", serverURL)
opCtx, opCancel := context.WithTimeout(a.bgCtx, 15*time.Second)
defer opCancel()
// Create new empty profile for this server
if err := a.lc.SwitchToEmptyProfile(opCtx); err != nil {
log.Printf("addServer: SwitchToEmptyProfile error: %v", err)
}
// Use Start() with UpdatePrefs like official CLI does
err := a.lc.Start(opCtx, ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: serverURL,
WantRunning: true,
},
})
if err != nil {
log.Printf("addServer: Start error: %v", err)
} else {
log.Println("addServer: Start OK")
}
// Then trigger interactive login to get BrowseToURL
if err := a.lc.StartLoginInteractive(opCtx); err != nil {
log.Printf("addServer: StartLoginInteractive error: %v", err)
} else {
log.Println("addServer: StartLoginInteractive OK")
}
// Poll for AuthURL and open browser
a.openAuthURL()
log.Println("addServer: done")
}
// openAuthURL polls Status.AuthURL and opens the browser when available.
// Uses bgCtx (not the caller's short operation context) because the auth URL
// can take longer than 5s to materialise on a cold tailscaled.
func (a *app) openAuthURL() {
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if a.bgCtx.Err() != nil {
return
}
ctx, cancel := context.WithTimeout(a.bgCtx, 3*time.Second)
st, err := a.lc.Status(ctx)
cancel()
if err != nil {
log.Printf("openAuthURL: Status error (will retry): %v", err)
time.Sleep(500 * time.Millisecond)
continue
}
if st.AuthURL != "" {
log.Printf("openAuthURL: opening %s", st.AuthURL)
exec.Command("rundll32", "url.dll,FileProtocolHandler", st.AuthURL).Start()
return
}
if st.BackendState == "Running" {
log.Println("openAuthURL: already running, no auth needed")
return
}
time.Sleep(500 * time.Millisecond)
}
log.Println("openAuthURL: timed out waiting for AuthURL")
}
// ── IPN Bus Watcher ─────────────────────────────────────
func (a *app) watchIPNBus() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in watchIPNBus (outer): %v", r)
}
}()
for {
// Reset prevState before each connection attempt so stale state
// from a prior session does not trigger false transitions.
a.mu.Lock()
a.prevState = ""
a.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in watchIPNBusInner: %v", r)
}
}()
if err := a.watchIPNBusInner(); err != nil {
log.Printf("watchIPNBus error: %v", err)
}
}()
// After the bus dies, force a status refresh + rebuild so the UI
// does not freeze on a stale snapshot while we wait to reconnect.
a.updateState()
a.triggerRebuild()
select {
case <-a.bgCtx.Done():
return
case <-time.After(3 * time.Second):
}
}
}
func (a *app) watchIPNBusInner() error {
watcher, err := a.lc.WatchIPNBus(a.bgCtx, 0)
if err != nil {
return err
}
defer watcher.Close()
for {
select {
case <-a.bgCtx.Done():
return nil
default:
}
n, err := watcher.Next()
if err != nil {
return err
}
// Track state transitions for logging.
if n.State != nil {
newState := n.State.String()
a.mu.Lock()
prev := a.prevState
a.prevState = newState
a.mu.Unlock()
if prev != "" && prev != newState {
log.Printf("State transition: %s -> %s", prev, newState)
}
// NOTE: We intentionally do NOT auto-logout on Running->NeedsLogin.
// A NeedsLogin state can be transient (network hiccup, key rotation,
// server maintenance). Calling Logout() here would deregister the node
// entirely, forcing full re-authentication. Let the UI reflect the state
// and allow the user to decide.
}
if n.State != nil || n.Prefs != nil {
a.triggerRebuild()
}
if url := n.BrowseToURL; url != nil {
log.Printf("BrowseToURL: %s", *url)
exec.Command("rundll32", "url.dll,FileProtocolHandler", *url).Start()
}
}
}
func (a *app) triggerRebuild() {
select {
case a.rebuildCh <- struct{}{}:
default:
}
}
// ── Profile & Path Helpers ──────────────────────────────
func profileTitle(p ipn.LoginProfile) string {
name := p.Name
if name == "" {
name = "(new profile)"
}
if p.ControlURL != "" {
u := strings.TrimPrefix(p.ControlURL, "https://")
u = strings.TrimPrefix(u, "http://")
u = strings.TrimSuffix(u, "/")
name += " @ " + u
}
return name
}
// ── Clipboard ───────────────────────────────────────────
func copyToClipboard(text string) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
openClipboard := windows.NewLazySystemDLL("user32.dll").NewProc("OpenClipboard")
closeClipboard := windows.NewLazySystemDLL("user32.dll").NewProc("CloseClipboard")
emptyClipboard := windows.NewLazySystemDLL("user32.dll").NewProc("EmptyClipboard")
setClipboardData := windows.NewLazySystemDLL("user32.dll").NewProc("SetClipboardData")
globalAlloc := windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalAlloc")
globalLock := windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalLock")
globalUnlock := windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalUnlock")
r, _, _ := openClipboard.Call(0)
if r == 0 {
return
}
defer closeClipboard.Call()
emptyClipboard.Call()
utf16, _ := windows.UTF16FromString(text)
size := len(utf16) * 2
const gmemMoveable = 0x0002
hMem, _, _ := globalAlloc.Call(gmemMoveable, uintptr(size))
if hMem == 0 {
return
}
ptr, _, _ := globalLock.Call(hMem)
if ptr == 0 {
return
}
for i, v := range utf16 {
*(*uint16)(unsafe.Pointer(ptr + uintptr(i*2))) = v
}
globalUnlock.Call(hMem)
const cfUnicodeText = 13
setClipboardData.Call(cfUnicodeText, hMem)
log.Printf("Copied to clipboard: %s", text)
}
// ── Windows Dialogs ─────────────────────────────────────
var (
user32 = windows.NewLazySystemDLL("user32.dll")
pMessageBoxW = user32.NewProc("MessageBoxW")
pDialogBoxIndirectParamW = user32.NewProc("DialogBoxIndirectParamW")
pEndDialog = user32.NewProc("EndDialog")
pGetDlgItemTextW = user32.NewProc("GetDlgItemTextW")
pSetDlgItemTextW = user32.NewProc("SetDlgItemTextW")
pGetDlgItem = user32.NewProc("GetDlgItem")
pSendMessageW = user32.NewProc("SendMessageW")
pSetForegroundWindow = user32.NewProc("SetForegroundWindow")
)
var (
dlgInputResult string
inputDlgCb = windows.NewCallback(inputDlgProcFn)
)
func showError(msg string) {
log.Printf("ERROR: %s", msg)
title, _ := windows.UTF16PtrFromString("Tailscale-Custom")
text, _ := windows.UTF16PtrFromString(msg)
pMessageBoxW.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(title)),
uintptr(0x00000000|0x00000010))
}
func inputDlgProcFn(hwnd, msg, wParam, lParam uintptr) uintptr {
const (
wmInitDialog = 0x0110
wmCommand = 0x0111
wmClose = 0x0010
idEdit = 101
emSetSel = 0x00B1
)
switch msg {
case wmInitDialog:
pSetForegroundWindow.Call(hwnd)
defText, _ := windows.UTF16PtrFromString("https://vpn.softs.business")
pSetDlgItemTextW.Call(hwnd, idEdit, uintptr(unsafe.Pointer(defText)))
editHwnd, _, _ := pGetDlgItem.Call(hwnd, idEdit)
pSendMessageW.Call(editHwnd, emSetSel, 8, 8)
return 1
case wmCommand:
switch int(wParam & 0xFFFF) {
case 1: // IDOK
buf := make([]uint16, 512)
pGetDlgItemTextW.Call(hwnd, idEdit, uintptr(unsafe.Pointer(&buf[0])), 512)
dlgInputResult = windows.UTF16ToString(buf)
pEndDialog.Call(hwnd, 1)
case 2: // IDCANCEL
dlgInputResult = ""
pEndDialog.Call(hwnd, 0)
}
case wmClose:
dlgInputResult = ""
pEndDialog.Call(hwnd, 0)
}
return 0
}
func inputDialog(title, prompt string) string {
log.Printf("inputDialog: title=%q", title)
runtime.LockOSThread()
defer runtime.UnlockOSThread()
dlgInputResult = ""
tmpl := buildInputDialogTemplate(title, prompt)
ret, _, _ := pDialogBoxIndirectParamW.Call(
0,
uintptr(unsafe.Pointer(&tmpl[0])),
0,
inputDlgCb,
0,
)
log.Printf("inputDialog: result=%q ret=%d", dlgInputResult, ret)
if ret == 0 {
return ""
}
result := strings.TrimSpace(dlgInputResult)
if result == "" || result == "https://" {
return ""
}
return result
}
type dlgBuilder struct{ buf []byte }
func (d *dlgBuilder) align(n int) {
for len(d.buf)%n != 0 {
d.buf = append(d.buf, 0)
}
}
func (d *dlgBuilder) w16(v uint16) {
d.buf = append(d.buf, byte(v), byte(v>>8))
}
func (d *dlgBuilder) w32(v uint32) {
d.buf = append(d.buf, byte(v), byte(v>>8), byte(v>>16), byte(v>>24))
}
func (d *dlgBuilder) ws16(v int16) { d.w16(uint16(v)) }
func (d *dlgBuilder) wstr(s string) {
for _, c := range s {
d.w16(uint16(c))
}
d.w16(0)
}
func buildInputDialogTemplate(title, prompt string) []byte {
const (
wsChild = 0x40000000
wsVisible = 0x10000000
wsCaption = 0x00C00000
wsSysMenu = 0x00080000
wsPopup = 0x80000000
bsPushBtn = 0x00000000
wsTabStop = 0x00010000
wsBorder = 0x00800000
esAutoHS = 0x00000080
dsSetFont = 0x00000040
dsModalFrm = 0x00000080
ds3DLook = 0x00000004
)
d := &dlgBuilder{}
// DLGTEMPLATE
d.w32(wsPopup | wsCaption | wsSysMenu | dsSetFont | dsModalFrm | ds3DLook)
d.w32(0)
d.w16(4)
d.ws16(0)
d.ws16(0)
d.ws16(280)
d.ws16(90)
d.w16(0)
d.w16(0)
d.wstr(title)
d.w16(9)
d.wstr("Segoe UI")
// Static label id=100
d.align(4)
d.w32(wsChild | wsVisible)
d.w32(0)
d.ws16(12)
d.ws16(12)
d.ws16(256)
d.ws16(14)
d.w16(100)
d.w16(0xFFFF)
d.w16(0x0082)
d.wstr(prompt)
d.w16(0)
// Edit id=101
d.align(4)
d.w32(wsChild | wsVisible | wsTabStop | wsBorder | esAutoHS)
d.w32(0)
d.ws16(12)
d.ws16(32)
d.ws16(256)
d.ws16(14)
d.w16(101)
d.w16(0xFFFF)
d.w16(0x0081)
d.w16(0)
d.w16(0)
// OK id=1
d.align(4)
d.w32(wsChild | wsVisible | wsTabStop | bsPushBtn)
d.w32(0)
d.ws16(150)
d.ws16(62)
d.ws16(55)
d.ws16(16)
d.w16(1)
d.w16(0xFFFF)
d.w16(0x0080)
d.wstr("Connect")
d.w16(0)
// Cancel id=2
d.align(4)
d.w32(wsChild | wsVisible | wsTabStop)
d.w32(0)
d.ws16(213)
d.ws16(62)
d.ws16(55)
d.ws16(16)
d.w16(2)
d.w16(0xFFFF)
d.w16(0x0080)
d.wstr("Cancel")
d.w16(0)
return d.buf
}