From ed8656930ab0d573209fd0b0b2149afa8a737aad Mon Sep 17 00:00:00 2001 From: Michael Herrmann Date: Thu, 7 Mar 2019 13:50:02 +0100 Subject: [PATCH] Automatic updates on Windows (#247) * Add Windows launcher The launcher looks at directory "Versions" next to its executable. It finds the latest version and runs the executable in that directory with the same name as itself. For instance: Aminal.exe <- the launcher Versions/ 1.0.0/ Aminal.exe 1.0.1/ Aminal.exe In this example, running the top-level Aminal.exe (the launcher) starts Versions/1.0.1/Aminal.exe. Having a launcher allows Aminal to be updated while it is running. For example, version 1.0.1 could be downloaded without disturbing running instances of Aminal 1.0.0. * 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. * Implement Uninstaller for Windows * Codesign Windows auto-update executables * Don't require Admin privileges for Win uninstaller * Remove references to fman * Explain automatic updates in windows.md * Limit installer go packages compilation only to Windows platform --- .gitignore | 3 +- Makefile | 49 +++++ windows.md | 95 ++++++++- windows/Uninstaller.nsi | 70 +++++++ windows/installer/AminalSetup.exe.manifest | 30 +++ windows/installer/installer.go | 218 +++++++++++++++++++++ windows/launcher/launcher.go | 124 ++++++++++++ windows/launcher/versioninfo.json | 35 ++++ windows/winutil/winutil.go | 27 +++ 9 files changed, 648 insertions(+), 3 deletions(-) create mode 100644 windows/Uninstaller.nsi create mode 100644 windows/installer/AminalSetup.exe.manifest create mode 100644 windows/installer/installer.go create mode 100644 windows/launcher/launcher.go create mode 100644 windows/launcher/versioninfo.json create mode 100644 windows/winutil/winutil.go diff --git a/.gitignore b/.gitignore index 627ca78..92ea164 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ aminal aminal.exe *.syso -.idea \ No newline at end of file +.idea +generated-src/ \ No newline at end of file diff --git a/Makefile b/Makefile index 8dc66ff..3861ce8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,11 @@ SHELL := /bin/bash BINARY := aminal FONTPATH := ./gui/packed-fonts +GEN_SRC_DIR := ./generated-src +VERSION_MAJOR := 0 +VERSION_MINOR := 9 +VERSION_PATCH := 0 +VERSION := ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH} .PHONY: build build: @@ -59,6 +64,50 @@ build-windows: windres -o aminal.syso aminal.rc go build -o ${BINARY}-windows-amd64.exe +.PHONY: launcher-windows +launcher-windows: build-windows + 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\Aminal" rmdir /S /Q "bin\windows\Aminal" + mkdir "bin\windows\Aminal\Versions\${VERSION}" + go build -o "bin\windows\Aminal\${BINARY}.exe" -ldflags "-H windowsgui" "${GEN_SRC_DIR}\launcher" + copy ${BINARY}-windows-amd64.exe "bin\windows\Aminal\Versions\${VERSION}\${BINARY}.exe" /Y + IF "${WINDOWS_CODESIGNING_CERT_PW}"=="" ECHO Environment variable WINDOWS_CODESIGNING_CERT_PW is not defined. & exit 1 + signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp bin\windows\Aminal\${BINARY}.exe + signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp /as /fd sha256 /td sha256 bin\windows\Aminal\${BINARY}.exe + signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp bin\windows\Aminal\Versions\${VERSION}\${BINARY}.exe + signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp /as /fd sha256 /td sha256 bin\windows\Aminal\Versions\${VERSION}\${BINARY}.exe + +.PHONY: uninstaller-windows +uninstaller-windows: launcher-windows + makensis "/XOutFile bin/windows/UninstallerSetup.exe" /NOCD windows\Uninstaller.nsi + cmd /c "bin\windows\UninstallerSetup.exe /S /D=%cd%\bin\windows\Aminal" + IF "${WINDOWS_CODESIGNING_CERT_PW}"=="" ECHO Environment variable WINDOWS_CODESIGNING_CERT_PW is not defined. & exit 1 + signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp bin\windows\Aminal\uninstall.exe + signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp /as /fd sha256 /td sha256 bin\windows\Aminal\uninstall.exe + +.PHONY: installer-windows +installer-windows: uninstaller-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\Aminal" -o "${GEN_SRC_DIR}/installer/data/data.go" "./bin/windows/Aminal/..." + 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 + IF "${WINDOWS_CODESIGNING_CERT_PW}"=="" ECHO Environment variable WINDOWS_CODESIGNING_CERT_PW is not defined. & exit 1 + signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp bin\windows\AminalSetup.exe + signtool sign /f windows\codesigning_certificate.pfx /p "${WINDOWS_CODESIGNING_CERT_PW}" /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp /as /fd sha256 /td sha256 bin\windows\AminalSetup.exe + .PHONY: build-darwin-native-travis build-darwin-native-travis: mkdir -p bin/darwin diff --git a/windows.md b/windows.md index 14d333a..0886ca4 100644 --- a/windows.md +++ b/windows.md @@ -12,8 +12,7 @@ cd %YOUR_PROJECT_WORKING_DIR% mkdir go\src\github.com\liamg cd go\src\github.com\liamg -git clone git@github.com:jumptrading/aminal-mirror.git -move aminal-mirror aminal +git clone https://github.com/liamg/aminal.git set GOPATH=%YOUR_PROJECT_WORKING_DIR%\go set GOBIN=%GOPATH%/bin @@ -28,3 +27,95 @@ go install Look for the aminal.exe built binary under your %GOBIN% path +### Building an installer for automatic updates: + +In addition to the above commands: + +``` +go get github.com/josephspurrier/goversioninfo/cmd/goversioninfo +go get golang.org/x/sys/windows +go get github.com/jteeuwen/go-bindata/... +``` + +Install NSIS and place it on the `PATH`: +``` +choco install nsis +set PATH=%PATH%;%ProgramFiles(x86)%\NSIS\Bin +``` + +Ensure `signtool.exe` is on the `PATH`. For instance, on Windows 10: + +``` +set PATH=%PATH%;%ProgramFiles(x86)%\Windows Kits\10\bin\10.0.17763.0\x64 +``` + +Copy your code signing certificate to `go\src\github.com\liamg\windows\codesigning_certificate.pfx`. + +Set the `WINDOWS_CODESIGNING_CERT_PW` to the password of your code signing certificate: + +``` +set WINDOWS_CODESIGNING_CERT_PW=PASSWORD +``` + +Compile Aminal and build the installer: + +``` +mingw32-make installer-windows +``` + +This produces several files in `bin/windows`. Their purpose is explained below. + +### How Aminal's automatic update mechanism works (on Windows) + +Aminal uses a technology called [Google Omaha](https://github.com/google/omaha) for automatic updates on Windows. It's the same technology which Google use to auto-update Chrome. For a quick introduction, see [this Google Omaha tutorial](https://fman.io/blog/google-omaha-tutorial/). + +Aminal has an online installer. This is a 1 MB executable, which was created using Google Omaha. Suppose it's called `OnlineInstaller.exe`. When a user runs it, the following things happen: + + * `OnlineInstaller.exe` installs Aminal's version of Google Omaha on the user's system. In particular, this creates two `AminalUpdateTask...` tasks in the Windows Task Scheduler. They're set up to run once per day in the background and check for updates. + * `OnlineInstaller.exe` contacts Aminal's update server and asks "what is the latest version?". The server responds with the URL to an _offline_ installer and some command line parameters. Say this offline installer is called `install-aminal-0.9.0.exe`. + * `OnlineInstaller.exe` downloads `install-aminal-0.9.0.exe` and invokes it with the given command line arguments, typically `-install`. + * `install-aminal-0.9.0.exe` performs the following steps: + * It installs Aminal 0.9.0 into `%LOCALAPPDATA%\Aminal`. + * It sets some registry keys in `HKCU\Software\Aminal`. This lets the `AminalUpdateTask...` tasks know which version of Aminal is installed. + * It creates a Start menu shortcut for starting Aminal. + +When the update tasks run, you will see `AminalUpdate.exe` in the Windows Task Manager. They use the registry to send the current Aminal version to the update server and ask "is there a new version?". If yes, the server again responds with the URL to an `.exe` and some command line parameters. In Aminal's current setup, this too is `install-aminal-0.9.0.exe` (if the user had, say, Aminal 0.8.9 installed). But this time the command line flag is `-update`. The `.exe` again installs the current version of Aminal and updates the registry. + +The offline installer `install-aminal-0.9.0.exe` is actually what's produced by the `mingw32-make installer-windows` command in the previous section. It is placed at `bin/windows/AminalSetup.exe` and supports the command line flags `-install`, `-update` or none. In the last case (i.e. when invoked without arguments), it acts as a normal offline installer to be invoked by the user, and does not set Omaha's registry keys. The source code for this installer lies in `windows/installer/installer.go`. + +Due to the asynchronous nature of the update tasks, it can happen that Aminal is running while a new version is being downloaded / installed. To prevent this from breaking Aminal's running instance, Aminal's install dir `%LOCALAPPDATA%\Aminal` contains the following hierarchy: + + * `Aminal.exe` + * `Versions/` + * `0.9.0/` + * `Aminal.exe` + +The top-level `Aminal.exe` is a launcher that always invokes the latest version. When an update is downloaded / installed, it is placed in a new subfolder of `Versions/`. For instance: + + * `Aminal.exe` + * `Versions/` + * `0.9.0/` + * `0.9.1/` + +The next time the top-level `Aminal.exe` is invoked, it runs `Versions/0.9.1/Aminal.exe`. + +The code for this top-level launcher is in `windows/launcher/launcher.go`. Its binary (and the current version subdirectory) is produced in `bin/windows` when you do `mingw32-make installer-windows`. + +#### Forcing updates + +By default, Aminal's (/Omaha's) update tasks only run once per day. To force an immediate update, perform the following steps: + + * Delete the registry key `HKCU\Software\Aminal\Updated\LastChecked`. + * Run the task `AminalUpdateTask...UA` in the Windows Task Scheduler. Press `F5`. You'll see its result change to `0x41301`. This means it's currently running. You'll also see `AminalUpdate.exe` in the Task _Scheduler_. Keep refreshing with `F5` until both disappear. + +#### Uninstalling Aminal + +The installer above adds an uninstaller to the user's _Add or remove programs_ panel in Windows. When the user goes to the Control Panel and uninstalls Aminal this way, the install directory and start menu entries are removed. Further, the registry key `HKCU\Software\Aminal\Clients\{35B0CF1E-FBB0-486F-A1DA-BE3A41DDC780}` is removed. What's not removed immediately is Omaha. (So you'll still see the update tasks in the Task Scheduler.) But! The next time the update tasks run, they realize by looking at the registry that Aminal is no longer installed and uninstall themselves (and Omaha). + +To work around some potential permission issues, the uninstaller is not implemented in Go (like the installer and launcher above). But via NSIS. The source code is in `windows/Uninstaller.nsi`. It's an "installer" whose sole purpose is to generate `bin/windows/Aminal/uninstall.exe`. + +#### Releasing a new version via automatic updates + +To release a new version, update the `VERSION` fields in `Makefile`. Then, invoke `mingw32-make installer-windows`. Log into the Omaha update server, add a new Version for Aminal that mirrors the one you set in `Makefile`. You'll need to add a trailing `.0` to the version number on the server, because Omaha uses four-tuples for versions (`0.9.1.0` instead of `0.9.1`). As the "File" for the version, upload `bin/windows/AminalSetup.exe`. Add two "Action"s: One for Event _install_, one for event _update_. In both cases, instruct the server to _Run_ `AminalSetup.exe`. For the install Event, supply Arguments `-install`. For the update Event, supply Arguments `-update`. + +Once you have done this, the background update tasks on your users' systems will (over the next 24 hours) download and install the new version of Aminal from the server. \ No newline at end of file diff --git a/windows/Uninstaller.nsi b/windows/Uninstaller.nsi new file mode 100644 index 0000000..b90b8b9 --- /dev/null +++ b/windows/Uninstaller.nsi @@ -0,0 +1,70 @@ +!include MUI2.nsh + +;-------------------------------- +!define MULTIUSER_EXECUTIONLEVEL Standard +;Add support for command-line args that let uninstaller know whether to +;uninstall machine- or user installation: +!define MULTIUSER_INSTALLMODE_COMMANDLINE +!include MultiUser.nsh +!include LogicLib.nsh + +Function .onInit + !insertmacro MULTIUSER_INIT +FunctionEnd + +Function un.onInit + !insertmacro MULTIUSER_UNINIT +FunctionEnd + +;-------------------------------- +;General + + Name "Aminal" + +;-------------------------------- +;Pages + + !insertmacro MUI_UNPAGE_CONFIRM + !insertmacro MUI_UNPAGE_INSTFILES + +;-------------------------------- +;Languages + + !insertmacro MUI_LANGUAGE "English" + +;-------------------------------- +;Installer Sections + +Section + SetOutPath "$InstDir" + WriteUninstaller "$InstDir\uninstall.exe" +SectionEnd + +;-------------------------------- +;Uninstaller Section + +!define UNINST_KEY \ + "Software\Microsoft\Windows\CurrentVersion\Uninstall\Aminal" +!define ROOT_KEY "Software\Aminal" +!define UPDATE_KEY \ + "${ROOT_KEY}\Update\Clients\{35B0CF1E-FBB0-486F-A1DA-BE3A41DDC780}" + +Section "Uninstall" + + RMDir /r "$InstDir\Versions" + Delete "$InstDir\Aminal.exe" + Delete "$InstDir\uninstall.exe" + ;Omaha leaves this directory behind. Delete if empty: + RMDir "$InstDir\CrashReports" + RMDir "$InstDir" + Delete "$SMPROGRAMS\Aminal.lnk" + DeleteRegKey SHCTX "${UNINST_KEY}" + DeleteRegKey SHCTX "${UPDATE_KEY}" + DeleteRegKey /ifempty SHCTX "${ROOT_KEY}\Update\Clients" + ;Try to speed up uninstall of Omaha: + DeleteRegValue SHCTX "${ROOT_KEY}\Update" "LastChecked" + DeleteRegKey /ifempty SHCTX "${ROOT_KEY}\Update" + WriteRegStr SHCTX "${ROOT_KEY}" "" "" + DeleteRegKey /ifempty SHCTX "${ROOT_KEY}" + +SectionEnd \ No newline at end of file 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..1d47f4b --- /dev/null +++ b/windows/installer/installer.go @@ -0,0 +1,218 @@ +// +build windows + +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) + launchAminal(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) + launchAminal(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 launchAminal(installDir string) { + cmd := exec.Command(getAminalExePath(installDir)) + check(cmd.Start()) +} + +func getAminalExePath(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 diff --git a/windows/launcher/launcher.go b/windows/launcher/launcher.go new file mode 100644 index 0000000..73da185 --- /dev/null +++ b/windows/launcher/launcher.go @@ -0,0 +1,124 @@ +// +build windows +//go:generate goversioninfo -icon=aminal.ico + +/* +Looks at directory "Versions" next to this executable. Finds the latest version +and runs the executable with the same name as this executable in that directory. +Eg.: + Aminal.exe (=launcher.exe) + Versions/ + 1.0.0/ + Aminal.exe + 1.0.1 + Aminal.exe +-> Launches Versions/1.0.1/Aminal.exe. +*/ + +package main + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "github.com/liamg/aminal/windows/winutil" +) + +type Version struct { + number [3]int + name string +} +type Versions []Version + +func main() { + executable, err := winutil.GetExecutablePath() + check(err) + executableDir, executableName := filepath.Split(executable) + versionsDir := filepath.Join(executableDir, "Versions") + latestVersion, err := getLatestVersion(versionsDir) + check(err) + target := filepath.Join(versionsDir, latestVersion, executableName) + cmd := exec.Command(target, os.Args[1:]...) + check(cmd.Start()) +} + +func getLatestVersion(versionsDir string) (string, error) { + potentialVersions, err := ioutil.ReadDir(versionsDir) + if err != nil { + return "", err + } + var versions Versions + for _, file := range potentialVersions { + if !file.IsDir() { + continue + } + version, err := parseVersionString(file.Name()) + if err != nil { + continue + } + versions = append(versions, version) + } + if len(versions) == 0 { + errMsg := fmt.Sprintf("No valid version in %s.", versionsDir) + return "", errors.New(errMsg) + } + sort.Sort(versions) + return versions[len(versions)-1].String(), nil +} + +func parseVersionString(version string) (Version, error) { + var result Version + result.name = version + err := error(nil) + version = strings.TrimSuffix(version, "-SNAPSHOT") + parts := strings.Split(version, ".") + if len(parts) != len(result.number) { + err = errors.New("Wrong number of parts.") + } else { + for i, partStr := range parts { + result.number[i], err = strconv.Atoi(partStr) + if err != nil { + break + } + } + } + return result, err +} + +func (arr Versions) Len() int { + return len(arr) +} + +func (arr Versions) Less(i, j int) bool { + for k, left := range arr[i].number { + right := arr[j].number[k] + if left > right { + return false + } else if left < right { + return true + } + } + fmt.Printf("%s < %s\n", arr[i], arr[j]) + return true +} + +func (arr Versions) Swap(i, j int) { + tmp := arr[j] + arr[j] = arr[i] + arr[i] = tmp +} + +func (version Version) String() string { + return version.name +} + +func check(e error) { + if e != nil { + panic(e) + } +} \ No newline at end of file diff --git a/windows/launcher/versioninfo.json b/windows/launcher/versioninfo.json new file mode 100644 index 0000000..af8c654 --- /dev/null +++ b/windows/launcher/versioninfo.json @@ -0,0 +1,35 @@ +{ + "FixedFileInfo": + { + "FileVersion": { + "Major": VERSION_MAJOR, + "Minor": VERSION_MINOR, + "Patch": VERSION_PATCH, + "Build": 0 + }, + "ProductVersion": { + "Major": VERSION_MAJOR, + "Minor": VERSION_MINOR, + "Patch": VERSION_PATCH, + "Build": 0 + }, + "FileFlagsMask": "3f", + "FileFlags ": "00", + "FileOS": "040004", + "FileType": "01", + "FileSubType": "00" + }, + "StringFileInfo": + { + "ProductName": "Aminal", + "ProductVersion": "VERSION", + "LegalCopyright": "Copyright 2018-YEAR Liam Galvin" + }, + "VarFileInfo": + { + "Translation": { + "LangID": "0409", + "CharsetID": "04B0" + } + } +} \ No newline at end of file diff --git a/windows/winutil/winutil.go b/windows/winutil/winutil.go new file mode 100644 index 0000000..99e7c5b --- /dev/null +++ b/windows/winutil/winutil.go @@ -0,0 +1,27 @@ +// +build windows + +package winutil + +import ( + "golang.org/x/sys/windows" + "unicode/utf16" + "unsafe" +) + +var ( + kernel = windows.MustLoadDLL("kernel32.dll") + getModuleFileNameProc = kernel.MustFindProc("GetModuleFileNameW") +) + +func GetExecutablePath() (string, error) { + var n uint32 + b := make([]uint16, windows.MAX_PATH) + size := uint32(len(b)) + bPtr := uintptr(unsafe.Pointer(&b[0])) + r0, _, e1 := getModuleFileNameProc.Call(0, bPtr, uintptr(size)) + n = uint32(r0) + if n == 0 { + return "", e1 + } + return string(utf16.Decode(b[0:n])), nil +} \ No newline at end of file