Files
rvbox/cmd/rvbox/installer_windows.go
T

208 lines
6.9 KiB
Go

//go:build windows
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
"github.com/rvbox/rvbox/internal/client/windowsservice"
"golang.org/x/sys/windows"
)
const (
installerInfoIcon = 0x00000040
installerErrorIcon = 0x00000010
)
var (
installerUser32 = syscall.NewLazyDLL("user32.dll")
installerMessageBox = installerUser32.NewProc("MessageBoxW")
)
// runInteractiveInstaller is the double-click entry point. It does no
// machine-wide mutation itself: ShellExecute's runas verb causes Windows to
// show the standard UAC consent prompt before the elevated child installs.
func runInteractiveInstaller(_ io.Writer, _ io.Writer) error {
executable, err := os.Executable()
if err != nil {
showInstallerMessage("RVBox setup could not locate its executable.", installerErrorIcon)
return err
}
file, err := windows.UTF16PtrFromString(executable)
if err != nil {
return err
}
arguments, err := windows.UTF16PtrFromString("--install-default")
if err != nil {
return err
}
if err := windows.ShellExecute(0, installerUTF16("runas"), file, arguments, nil, windows.SW_SHOWNORMAL); err != nil {
showInstallerMessage("RVBox setup was cancelled or could not obtain administrator approval.\n\nNo changes were made.", installerErrorIcon)
return fmt.Errorf("request installer elevation: %w", err)
}
return nil
}
// installPackagedDefault executes only in the UAC-elevated child. It preserves
// a pre-existing operator configuration, atomically updates the executable,
// installs/updates the SCM service, and starts it.
func installPackagedDefault(_ io.Writer) error {
err := installPackagedDefaultFiles()
if err != nil {
showInstallerMessage("RVBox could not be installed.\n\n"+err.Error()+"\n\nNo existing configuration was replaced.", installerErrorIcon)
return err
}
showInstallerMessage("RVBox is installed and its Windows service is starting.\n\nThe notification-area icon starts automatically at the next sign-in.", installerInfoIcon)
return nil
}
func installPackagedDefaultFiles() error {
if strings.TrimSpace(bootstrapServerURL) == "" {
return errors.New("this RVBox package was built without a bootstrap server URL")
}
source, err := os.Executable()
if err != nil {
return fmt.Errorf("locate installer executable: %w", err)
}
programFiles := os.Getenv("ProgramFiles")
if programFiles == "" {
programFiles = `C:\Program Files`
}
programData := os.Getenv("ProgramData")
if programData == "" {
programData = `C:\ProgramData`
}
targetDirectory := filepath.Join(programFiles, "RVBox")
targetExecutable := filepath.Join(targetDirectory, "rvbox.exe")
configDirectory := filepath.Join(programData, "RVBox")
configPath := filepath.Join(configDirectory, "client.toml")
// A running service can keep the prior executable open. Stop it before the
// atomic replacement; a missing service is already a successful first run.
if err := windowsservice.Stop(30); err != nil {
return fmt.Errorf("stop existing RVBox service: %w", err)
}
if err := os.MkdirAll(targetDirectory, 0o755); err != nil {
return fmt.Errorf("create installation directory: %w", err)
}
if !sameWindowsPath(source, targetExecutable) {
if err := copyInstallerExecutable(source, targetExecutable); err != nil {
return err
}
}
if err := os.MkdirAll(configDirectory, 0o700); err != nil {
return fmt.Errorf("create configuration directory: %w", err)
}
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
hostname, hostnameErr := os.Hostname()
if hostnameErr != nil {
hostname = "rvbox-client"
}
content, configErr := packagedClientConfig(bootstrapServerURL, hostname)
if configErr != nil {
return configErr
}
if err := writeMachineConfig(configPath, content); err != nil {
return err
}
} else if err != nil {
return fmt.Errorf("inspect existing configuration: %w", err)
}
if err := windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: targetExecutable, ConfigPath: configPath, Startup: windowsservice.StartupAutomatic}); err != nil {
return fmt.Errorf("install and start RVBox service: %w", err)
}
return nil
}
func copyInstallerExecutable(source, target string) error {
input, err := os.Open(source)
if err != nil {
return fmt.Errorf("open installer executable: %w", err)
}
defer input.Close()
temporary := target + ".new"
_ = os.Remove(temporary)
output, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755)
if err != nil {
return fmt.Errorf("create replacement executable: %w", err)
}
_, copyErr := io.Copy(output, input)
if syncErr := output.Sync(); copyErr == nil {
copyErr = syncErr
}
if closeErr := output.Close(); copyErr == nil {
copyErr = closeErr
}
if copyErr != nil {
_ = os.Remove(temporary)
return fmt.Errorf("copy installer executable: %w", copyErr)
}
from, fromErr := windows.UTF16PtrFromString(temporary)
to, toErr := windows.UTF16PtrFromString(target)
if fromErr != nil || toErr != nil {
_ = os.Remove(temporary)
return errors.New("encode replacement executable path")
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
_ = os.Remove(temporary)
return fmt.Errorf("activate replacement executable: %w", err)
}
return nil
}
func writeMachineConfig(path string, content []byte) error {
temporary := path + ".new"
_ = os.Remove(temporary)
if err := os.WriteFile(temporary, content, 0o600); err != nil {
return fmt.Errorf("write default configuration: %w", err)
}
from, fromErr := windows.UTF16PtrFromString(temporary)
to, toErr := windows.UTF16PtrFromString(path)
if fromErr != nil || toErr != nil {
_ = os.Remove(temporary)
return errors.New("encode configuration path")
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
_ = os.Remove(temporary)
return fmt.Errorf("activate default configuration: %w", err)
}
return protectMachineConfig(path)
}
func protectMachineConfig(path string) error {
descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;SY)(A;;FA;;;BA)")
if err != nil {
return err
}
dacl, _, err := descriptor.DACL()
if err != nil {
return err
}
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
return fmt.Errorf("protect generated configuration: %w", err)
}
return nil
}
func sameWindowsPath(first, second string) bool {
return strings.EqualFold(filepath.Clean(first), filepath.Clean(second))
}
func installerUTF16(value string) *uint16 {
encoded, _ := windows.UTF16PtrFromString(value)
return encoded
}
func showInstallerMessage(message string, icon uintptr) {
caption := installerUTF16("RVBox Setup")
text := installerUTF16(message)
_, _, _ = installerMessageBox.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(caption)), icon)
}