From 8fd76c876bae6252ef49c39244491fbf192b9ed6 Mon Sep 17 00:00:00 2001 From: Michael Herrmann Date: Mon, 4 Mar 2019 16:34:32 +0100 Subject: [PATCH] Implement a command-line installer for Windows It can be generated with the Make target installer-windows. It requires that you ran Make target launcher-windows before. --- .gitignore | 2 +- Makefile | 40 ++-- windows/installer/AminalSetup.exe.manifest | 30 +++ windows/installer/installer.go | 216 +++++++++++++++++++++ 4 files changed, 273 insertions(+), 15 deletions(-) create mode 100644 windows/installer/AminalSetup.exe.manifest create mode 100644 windows/installer/installer.go diff --git a/.gitignore b/.gitignore index 3a53cfc..92ea164 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ aminal aminal.exe *.syso .idea -tmp/ \ No newline at end of file +generated-src/ \ No newline at end of file diff --git a/Makefile b/Makefile index 349d864..eacc836 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL := /bin/bash BINARY := aminal FONTPATH := ./gui/packed-fonts -TMPDIR := ./tmp +GEN_SRC_DIR := ./generated-src VERSION_MAJOR := 0 VERSION_MINOR := 9 VERSION_PATCH := 0 @@ -57,19 +57,31 @@ build-windows: .PHONY: launcher-windows launcher-windows: build-windows - if exist "${TMPDIR}\launcher-src" rmdir /S /Q "${TMPDIR}\launcher-src" - xcopy "windows\launcher\*.*" "${TMPDIR}\launcher-src" /K /H /Y /Q /I - powershell -Command "(gc ${TMPDIR}\launcher-src\versioninfo.json) -replace 'VERSION_MAJOR', '${VERSION_MAJOR}' | Out-File -Encoding default ${TMPDIR}\launcher-src\versioninfo.json" - powershell -Command "(gc ${TMPDIR}\launcher-src\versioninfo.json) -replace 'VERSION_MINOR', '${VERSION_MINOR}' | Out-File -Encoding default ${TMPDIR}\launcher-src\versioninfo.json" - powershell -Command "(gc ${TMPDIR}\launcher-src\versioninfo.json) -replace 'VERSION_PATCH', '${VERSION_PATCH}' | Out-File -Encoding default ${TMPDIR}\launcher-src\versioninfo.json" - powershell -Command "(gc ${TMPDIR}\launcher-src\versioninfo.json) -replace 'VERSION', '${VERSION}' | Out-File -Encoding default ${TMPDIR}\launcher-src\versioninfo.json" - powershell -Command "(gc ${TMPDIR}\launcher-src\versioninfo.json) -replace 'YEAR', (Get-Date -UFormat '%Y') | Out-File -Encoding default ${TMPDIR}\launcher-src\versioninfo.json" - copy aminal.ico "${TMPDIR}\launcher-src" /Y - go generate "${TMPDIR}\launcher-src" - if exist "${TMPDIR}\launcher" rmdir /S /Q "${TMPDIR}\launcher" - mkdir "${TMPDIR}\launcher\Versions\${VERSION}" - go build -o "${TMPDIR}\launcher\${BINARY}.exe" -ldflags "-H windowsgui" "${TMPDIR}\launcher-src" - copy ${BINARY}-windows-amd64.exe "${TMPDIR}\launcher\Versions\${VERSION}\${BINARY}.exe" /Y + if exist "${GEN_SRC_DIR}\launcher" rmdir /S /Q "${GEN_SRC_DIR}\launcher" + xcopy "windows\launcher\*.*" "${GEN_SRC_DIR}\launcher" /K /H /Y /Q /I + powershell -Command "(gc ${GEN_SRC_DIR}\launcher\versioninfo.json) -creplace 'VERSION_MAJOR', '${VERSION_MAJOR}' | Out-File -Encoding default ${GEN_SRC_DIR}\launcher\versioninfo.json" + powershell -Command "(gc ${GEN_SRC_DIR}\launcher\versioninfo.json) -creplace 'VERSION_MINOR', '${VERSION_MINOR}' | Out-File -Encoding default ${GEN_SRC_DIR}\launcher\versioninfo.json" + powershell -Command "(gc ${GEN_SRC_DIR}\launcher\versioninfo.json) -creplace 'VERSION_PATCH', '${VERSION_PATCH}' | Out-File -Encoding default ${GEN_SRC_DIR}\launcher\versioninfo.json" + powershell -Command "(gc ${GEN_SRC_DIR}\launcher\versioninfo.json) -creplace 'VERSION', '${VERSION}' | Out-File -Encoding default ${GEN_SRC_DIR}\launcher\versioninfo.json" + powershell -Command "(gc ${GEN_SRC_DIR}\launcher\versioninfo.json) -creplace 'YEAR', (Get-Date -UFormat '%Y') | Out-File -Encoding default ${GEN_SRC_DIR}\launcher\versioninfo.json" + copy aminal.ico "${GEN_SRC_DIR}\launcher" /Y + go generate "${GEN_SRC_DIR}\launcher" + if exist "bin\windows\launcher" rmdir /S /Q "bin\windows\launcher" + mkdir "bin\windows\launcher\Versions\${VERSION}" + go build -o "bin\windows\launcher\${BINARY}.exe" -ldflags "-H windowsgui" "${GEN_SRC_DIR}\launcher" + copy ${BINARY}-windows-amd64.exe "bin\windows\launcher\Versions\${VERSION}\${BINARY}.exe" /Y + +.PHONY: installer-windows +installer-windows: + if exist "${GEN_SRC_DIR}\installer" rmdir /S /Q "${GEN_SRC_DIR}\installer" + xcopy "windows\installer\*.*" "${GEN_SRC_DIR}\installer" /K /H /Y /Q /I + powershell -Command "(gc ${GEN_SRC_DIR}\installer\installer.go) -creplace 'VERSION', '${VERSION}' | Out-File -Encoding default ${GEN_SRC_DIR}\installer\installer.go" + go-bindata -prefix "bin\windows\launcher" -o "${GEN_SRC_DIR}/installer/data/data.go" "./bin/windows/launcher/..." + powershell -Command "(gc ${GEN_SRC_DIR}\installer\data\data.go) -creplace 'package main', 'package data' | Out-File -Encoding default ${GEN_SRC_DIR}\installer\data\data.go" + go build -o bin/windows/AminalSetup.exe -ldflags "-H windowsgui" "${GEN_SRC_DIR}/installer/installer.go" + rem If an .exe name contains "installer", "setup" etc., then at least Windows 10 automatically + rem opens a UAC prompt upon opening it. To avoid this, we add a compatibility manifest to the .exe. + mt -manifest windows\installer\AminalSetup.exe.manifest -outputresource:bin\windows\AminalSetup.exe;1 .PHONY: build-darwin-native-travis build-darwin-native-travis: diff --git a/windows/installer/AminalSetup.exe.manifest b/windows/installer/AminalSetup.exe.manifest new file mode 100644 index 0000000..14df669 --- /dev/null +++ b/windows/installer/AminalSetup.exe.manifest @@ -0,0 +1,30 @@ + + + + + True/PM + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/installer/installer.go b/windows/installer/installer.go new file mode 100644 index 0000000..c4f172b --- /dev/null +++ b/windows/installer/installer.go @@ -0,0 +1,216 @@ +package main + +import ( + "bufio" + "errors" + "golang.org/x/sys/windows/registry" + "os" + "os/user" + "strings" + "path/filepath" + "github.com/liamg/aminal/windows/winutil" + "github.com/liamg/aminal/generated-src/installer/data" + "text/template" + "io/ioutil" + "os/exec" + "syscall" + "flag" +) + +const Version = "VERSION" +const ProductId = `{35B0CF1E-FBB0-486F-A1DA-BE3A41DDC780}` + +func main() { + doInstallPtr := flag.Bool("install", false, "Install Aminal") + doUpdatePtr := flag.Bool("update", false, "Update Aminal") + flag.Parse() + + var installDir string + isUserInstall := strings.HasPrefix(os.Args[0], os.Getenv("LOCALAPPDATA")) + if *doInstallPtr { + installDir = getInstallDirWhenManagedByOmaha() + extractAssets(installDir) + createRegistryKeysForUninstaller(installDir, isUserInstall) + updateVersionInRegistry(isUserInstall) + createStartMenuShortcut(installDir, isUserInstall) + launchFman(installDir) + } else if *doUpdatePtr { + installDir = getInstallDirWhenManagedByOmaha() + extractAssets(installDir) + updateVersionInRegistry(isUserInstall) + removeOldVersions(installDir) + } else { + // Offline installer. + // We don't know whether we're being executed with Admin privileges. + // It's also not easy to determine. So perform user install: + isUserInstall = true + installDir = getDefaultInstallDir() + extractAssets(installDir) + createRegistryKeysForUninstaller(installDir, isUserInstall) + createStartMenuShortcut(installDir, isUserInstall) + launchFman(installDir) + } +} + +func getInstallDirWhenManagedByOmaha() string { + executablePath, err := winutil.GetExecutablePath() + check(err) + result := executablePath + prevResult := "" + for filepath.Base(result) != "Aminal" { + prevResult = result + result = filepath.Dir(result) + if result == prevResult { + break + } + } + if result == prevResult { + msg := "Could not find parent directory 'Aminal' above " + executablePath + check(errors.New(msg)) + } + return result +} + +func getDefaultInstallDir() string { + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + panic("Environment variable LOCALAPPDATA is not set.") + } + return filepath.Join(localAppData, "Aminal") +} + +func extractAssets(installDir string) { + for _, relPath := range data.AssetNames() { + bytes, err := data.Asset(relPath) + check(err) + absPath := filepath.Join(installDir, relPath) + check(os.MkdirAll(filepath.Dir(absPath), 0755)) + f, err := os.OpenFile(absPath, os.O_CREATE, 0755) + check(err) + defer f.Close() + w := bufio.NewWriter(f) + _, err = w.Write(bytes) + check(err) + w.Flush() + } +} + +func createRegistryKeysForUninstaller(installDir string, isUserInstall bool) { + regRoot := getRegistryRoot(isUserInstall) + uninstKey := `Software\Microsoft\Windows\CurrentVersion\Uninstall\Aminal` + writeRegStr(regRoot, uninstKey, "", installDir) + writeRegStr(regRoot, uninstKey, "DisplayName", "Aminal") + writeRegStr(regRoot, uninstKey, "Publisher", "Liam Galvin") + uninstaller := filepath.Join(installDir, "uninstall.exe") + uninstString := `"` + uninstaller + `"` + if isUserInstall { + uninstString += " /CurrentUser" + } else { + uninstString += " /AllUsers" + } + writeRegStr(regRoot, uninstKey, "UninstallString", uninstString) +} + +func updateVersionInRegistry(isUserInstall bool) { + regRoot := getRegistryRoot(isUserInstall) + updateKey := `Software\Aminal\Update\Clients\` + ProductId + writeRegStr(regRoot, updateKey, "pv", Version + ".0") + writeRegStr(regRoot, updateKey, "name", "Aminal") +} + +func getRegistryRoot(isUserInstall bool) registry.Key { + if isUserInstall { + return registry.CURRENT_USER + } + return registry.LOCAL_MACHINE +} + +func writeRegStr(regRoot registry.Key, keyPath string, valueName string, value string) { + const mode = registry.WRITE|registry.WOW64_32KEY + key, _, err := registry.CreateKey(regRoot, keyPath, mode) + check(err) + defer key.Close() + check(key.SetStringValue(valueName, value)) +} + +func createStartMenuShortcut(installDir string, isUserInstall bool) { + startMenuDir := getStartMenuDir(isUserInstall) + linkPath := filepath.Join(startMenuDir, "Programs", "Aminal.lnk") + targetPath := filepath.Join(installDir, "Aminal.exe") + createShortcut(linkPath, targetPath) +} + +func getStartMenuDir(isUserInstall bool) string { + if isUserInstall { + usr, err := user.Current() + check(err) + return usr.HomeDir + `\AppData\Roaming\Microsoft\Windows\Start Menu` + } else { + return os.Getenv("ProgramData") + `\Microsoft\Windows\Start Menu` + } +} + +func createShortcut(linkPath, targetPath string) { + type Shortcut struct { + LinkPath string + TargetPath string + } + tmpl := template.New("createLnk.vbs") + tmpl, err := tmpl.Parse(`Set oWS = WScript.CreateObject("WScript.Shell") +sLinkFile = "{{.LinkPath}}" +Set oLink = oWS.CreateShortcut(sLinkFile) +oLink.TargetPath = "{{.TargetPath}}" +oLink.Save +WScript.Quit 0`) + check(err) + tmpDir, err := ioutil.TempDir("", "Aminal") + check(err) + createLnk := filepath.Join(tmpDir, "createLnk.vbs") + defer os.RemoveAll(tmpDir) + f, err := os.Create(createLnk) + check(err) + defer f.Close() + w := bufio.NewWriter(f) + shortcut := Shortcut{linkPath, targetPath} + check(tmpl.Execute(w, shortcut)) + w.Flush() + f.Close() + cmd := exec.Command("cscript", f.Name()) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + check(cmd.Run()) +} + +func launchFman(installDir string) { + cmd := exec.Command(getFmanExePath(installDir)) + check(cmd.Start()) +} + +func getFmanExePath(installDir string) string { + return filepath.Join(installDir, "Aminal.exe") +} + +func removeOldVersions(installDir string) { + versionsDir := filepath.Join(installDir, "Versions") + versions, err := ioutil.ReadDir(versionsDir) + check(err) + for _, version := range versions { + if version.Name() == Version { + continue + } + versionPath := filepath.Join(versionsDir, version.Name()) + // Try deleting the main executable first. We do this to prevent a + // version that is still running from being deleted. + mainExecutable := filepath.Join(versionPath, "Aminal.exe") + err = os.Remove(mainExecutable) + if err == nil { + // Remove the rest: + check(os.RemoveAll(versionPath)) + } + } +} + +func check(e error) { + if e != nil { + panic(e) + } +} \ No newline at end of file