feat(windows): rewrite NSIS installer, add pgtidy update command

This commit is contained in:
Hein
2026-10-06 16:15:21 +02:00
parent e9c0d52ae9
commit 96ee6c659b
12 changed files with 606 additions and 348 deletions
+3 -3
View File
@@ -231,10 +231,10 @@ jobs:
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-X main.version=${PKGVER}" \
-o pgtidy.exe \
-o pgtidy-windows-amd64.exe \
./cmd/pgtidy
makensis -DVERSION="${PKGVER}" -DSRC_EXE="$(pwd)/pgtidy.exe" windows/installer.nsi
makensis -DVERSION="${PKGVER}" -DEXE="$PWD/pgtidy-windows-amd64.exe" -DOUT="$PWD/pgtidy-setup-windows-amd64.exe" windows/installer.nsi
- name: Upload to release
run: |
@@ -244,7 +244,7 @@ jobs:
-H "Authorization: token ${GITHUB_TOKEN}")
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
[ -z "$UPLOAD_URL" ] && { echo "upload_url not found: $RELEASE"; exit 1; }
for f in windows/pgtidy-setup-*.exe; do
for f in pgtidy-setup-windows-amd64.exe; do
echo "Uploading $(basename "$f")..."
curl -s -X POST "${UPLOAD_URL}?name=$(basename "$f")" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-62
View File
@@ -1,62 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
- name: Format check
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Files need gofmt:"
echo "$unformatted"
exit 1
fi
build-snapshot:
name: Build snapshot
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --snapshot --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: dist-snapshot
path: dist/
retention-days: 7
-91
View File
@@ -1,91 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
vscode-package:
name: VSCode Extension
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: editors/vscode/package-lock.json
- name: Set version from tag
working-directory: editors/vscode
run: npm pkg set version="${GITHUB_REF_NAME#v}"
- name: Install and package
working-directory: editors/vscode
run: |
npm ci
npm run compile
npm run package
- name: Upload to release
run: gh release upload ${{ github.ref_name }} editors/vscode/*.vsix
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
datagrip-package:
name: DataGrip Plugin
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Set version from tag
working-directory: editors/datagrip
run: sed -i "s/^pluginVersion=.*/pluginVersion=${GITHUB_REF_NAME#v}/" gradle.properties
- name: Build plugin
working-directory: editors/datagrip
run: ./gradlew buildPlugin
- name: Upload to release
run: gh release upload ${{ github.ref_name }} editors/datagrip/build/distributions/*.zip
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -1
View File
@@ -2,7 +2,7 @@ APP := pgtidy
CMD := ./cmd/pgtidy
DIST := dist
.PHONY: build test lint vet fmt clean release release-version snapshot vscode-compile vscode-package
.PHONY: installer-windows build test lint vet fmt clean release release-version snapshot vscode-compile vscode-package
## build: compile binary for the current platform
build:
@@ -25,6 +25,11 @@ lint: vet
test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1)
golangci-lint run ./...
## installer-windows: build the Windows binary and NSIS installer (requires makensis)
installer-windows:
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" -o $(DIST)/pgtidy-windows-amd64.exe $(CMD)
makensis -DVERSION=$$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$$' || echo 0.0.0) -DEXE=$(CURDIR)/$(DIST)/pgtidy-windows-amd64.exe -DOUT=$(CURDIR)/$(DIST)/pgtidy-setup-windows-amd64.exe windows/installer.nsi
## clean: remove build artifacts
clean:
rm -rf $(DIST)
+4
View File
@@ -18,6 +18,9 @@ go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest
Or download a pre-built binary from [Releases](https://git.warky.dev/wdevs/pgtidy/releases).
**Windows** — run `pgtidy-setup-windows-amd64.exe` from the release. It installs to
`%ProgramFiles%\PgTidy` and adds it to the system `PATH`. Update later with `pgtidy update`.
---
## CLI
@@ -27,6 +30,7 @@ pgtidy fmt [flags] [files...] Format SQL/PL-pgSQL (stdin if no files)
pgtidy lint [flags] [files...] Lint SQL
pgtidy config Print effective configuration
pgtidy lsp Start LSP server (stdio)
pgtidy update [--check] [-y] Check for a newer release (installs it on Windows)
pgtidy version
```
+3
View File
@@ -28,6 +28,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return cmdLsp(args[1:], stdin, stdout, stderr)
case "config":
return cmdConfig(args[1:], stdin, stdout, stderr)
case "update":
return cmdUpdate(args[1:], stdin, stdout, stderr)
case "version", "--version", "-v":
_, _ = fmt.Fprintf(stdout, "pgtidy %s\n", version)
return 0
@@ -49,6 +51,7 @@ Usage:
pgtidy lint [flags] [files...] Lint SQL (stdin if no files)
pgtidy config Print effective configuration
pgtidy lsp Start LSP server (stdio, for editors)
pgtidy update [--check] [-y] Check for a newer release (installs on Windows)
pgtidy version Print version
pgtidy help Show this help
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"git.warky.dev/wdevs/pgtidy/pkg/updatecheck"
)
// windowsInstallerAsset is the release asset built by the NSIS installer step.
const windowsInstallerAsset = "pgtidy-setup-windows-amd64.exe"
// cmdUpdate implements `pgtidy update`: check the latest release and, on
// Windows, offer to download and run the installer.
func cmdUpdate(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var checkOnly, assumeYes bool
for _, a := range args {
switch a {
case "--check":
checkOnly = true
case "-y", "--yes":
assumeYes = true
case "-h", "--help":
_, _ = fmt.Fprint(stdout, `pgtidy update — check for a newer release
Usage:
pgtidy update [--check] [-y|--yes]
--check Only report whether an update is available
-y, --yes Update without prompting
On Windows the installer is downloaded and started; elsewhere the release
page URL is shown.
`)
return 0
default:
_, _ = fmt.Fprintf(stderr, "pgtidy update: unknown flag %q\n", a)
return 2
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
err := runUpdate(ctx, updateOptions{
current: version,
apiURL: updatecheck.DefaultAPIURL,
goos: runtime.GOOS,
checkOnly: checkOnly,
assumeYes: assumeYes,
in: stdin,
out: stdout,
install: downloadAndRunInstaller,
})
if err != nil {
_, _ = fmt.Fprintf(stderr, "pgtidy update: %v\n", err)
return 1
}
return 0
}
type updateOptions struct {
current string
apiURL string
goos string
checkOnly bool
assumeYes bool
in io.Reader
out io.Writer
// install downloads and starts the installer found at url.
install func(ctx context.Context, url string, out io.Writer) error
}
func runUpdate(ctx context.Context, o updateOptions) error {
rel, err := updatecheck.Latest(ctx, nil, o.apiURL)
if err != nil {
return err
}
if !updatecheck.IsNewer(o.current, rel.Tag) {
_, _ = fmt.Fprintf(o.out, "PgTidy %s is up to date (latest release: %s)\n", o.current, rel.Tag)
return nil
}
_, _ = fmt.Fprintf(o.out, "A newer PgTidy is available: %s (installed: %s)\n", rel.Tag, o.current)
if o.checkOnly {
_, _ = fmt.Fprintf(o.out, "Release: %s\n", rel.URL)
return nil
}
installer, canInstall := rel.FindAsset(windowsInstallerAsset)
if o.goos != "windows" || !canInstall {
_, _ = fmt.Fprintf(o.out, "Download it from: %s\n", rel.URL)
return nil
}
if !o.assumeYes && !confirm(o.in, o.out, "Download and run the installer now?") {
_, _ = fmt.Fprintf(o.out, "Skipped. Release: %s\n", rel.URL)
return nil
}
return o.install(ctx, installer.URL, o.out)
}
// confirm asks a yes/no question, defaulting to no.
func confirm(in io.Reader, out io.Writer, question string) bool {
_, _ = fmt.Fprintf(out, "%s [y/N]: ", question)
line, _ := bufio.NewReader(in).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
}
return false
}
// downloadAndRunInstaller saves the installer to a temp directory and starts
// it detached so this process can exit and release pgtidy.exe.
func downloadAndRunInstaller(ctx context.Context, url string, out io.Writer) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("downloading installer: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("downloading installer: unexpected status %s", resp.Status)
}
dir, err := os.MkdirTemp("", "pgtidy-update-")
if err != nil {
return err
}
path := filepath.Join(dir, windowsInstallerAsset)
f, err := os.Create(path)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
_ = f.Close()
return fmt.Errorf("downloading installer: %w", err)
}
if err := f.Close(); err != nil {
return err
}
_, _ = fmt.Fprintf(out, "Starting installer: %s\n", path)
return exec.Command(path).Start()
}
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func releaseServer(t *testing.T, tag string, withInstaller bool) *httptest.Server {
t.Helper()
assets := ""
if withInstaller {
assets = fmt.Sprintf(`{"name":%q,"browser_download_url":"https://x/setup.exe"}`, windowsInstallerAsset)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"html_url":"https://x/release","assets":[%s]}`, tag, assets)
}))
t.Cleanup(srv.Close)
return srv
}
func TestRunUpdate(t *testing.T) {
tests := []struct {
name string
current string
latest string
withInstaller bool
goos string
checkOnly bool
assumeYes bool
stdin string
wantOut []string
wantInstall bool
}{
{name: "up to date", current: "v1.0.5", latest: "v1.0.5", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
{name: "dev build never prompts", current: "dev", latest: "v9.0.0", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
{name: "check only", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, checkOnly: true, wantOut: []string{"v1.0.6", "https://x/release"}},
{name: "non-windows shows url", current: "v1.0.5", latest: "v1.0.6", goos: "linux", withInstaller: true, wantOut: []string{"Download it from: https://x/release"}},
{name: "windows without installer asset", current: "v1.0.5", latest: "v1.0.6", goos: "windows", wantOut: []string{"Download it from"}},
{name: "windows prompt yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "y\n", wantOut: []string{"[y/N]"}, wantInstall: true},
{name: "windows prompt no", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "n\n", wantOut: []string{"Skipped"}},
{name: "windows prompt empty", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, wantOut: []string{"Skipped"}},
{name: "windows --yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, assumeYes: true, wantInstall: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := releaseServer(t, tt.latest, tt.withInstaller)
var out bytes.Buffer
var installedURL string
err := runUpdate(context.Background(), updateOptions{
current: tt.current, apiURL: srv.URL, goos: tt.goos,
checkOnly: tt.checkOnly, assumeYes: tt.assumeYes,
in: strings.NewReader(tt.stdin), out: &out,
install: func(_ context.Context, url string, _ io.Writer) error {
installedURL = url
return nil
},
})
if err != nil {
t.Fatal(err)
}
for _, want := range tt.wantOut {
if !strings.Contains(out.String(), want) {
t.Errorf("output missing %q:\n%s", want, out.String())
}
}
if (installedURL != "") != tt.wantInstall {
t.Errorf("installer called = %v, want %v", installedURL != "", tt.wantInstall)
}
if tt.wantInstall && installedURL != "https://x/setup.exe" {
t.Errorf("installer url = %q", installedURL)
}
})
}
}
func TestRunUpdate_Errors(t *testing.T) {
bad := httptest.NewServer(http.NotFoundHandler())
defer bad.Close()
if err := runUpdate(context.Background(), updateOptions{current: "v1.0.0", apiURL: bad.URL, out: io.Discard}); err == nil {
t.Error("expected lookup error")
}
srv := releaseServer(t, "v1.0.6", true)
want := errors.New("boom")
err := runUpdate(context.Background(), updateOptions{
current: "v1.0.5", apiURL: srv.URL, goos: "windows", assumeYes: true, out: io.Discard,
install: func(context.Context, string, io.Writer) error { return want },
})
if !errors.Is(err, want) {
t.Errorf("err = %v", err)
}
}
func TestDownloadAndRunInstaller_BadStatus(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
if err := downloadAndRunInstaller(context.Background(), srv.URL, io.Discard); err == nil {
t.Error("expected error for non-200")
}
if err := downloadAndRunInstaller(context.Background(), "://bad", io.Discard); err == nil {
t.Error("expected error for bad url")
}
}
+115
View File
@@ -0,0 +1,115 @@
// Package updatecheck looks up the latest PgTidy release on the project's
// Gitea instance and compares it against the running version.
package updatecheck
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// DefaultAPIURL is the Gitea endpoint that returns the latest release.
const DefaultAPIURL = "https://git.warky.dev/api/v1/repos/wdevs/pgtidy/releases/latest"
// Asset is a downloadable file attached to a release.
type Asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}
// Release is the subset of the Gitea release payload that is needed.
type Release struct {
Tag string `json:"tag_name"`
URL string `json:"html_url"`
Assets []Asset `json:"assets"`
}
// Latest fetches the latest release from apiURL. A nil client uses a client
// with a 10 second timeout.
func Latest(ctx context.Context, client *http.Client, apiURL string) (*Release, error) {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("checking for updates: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("checking for updates: unexpected status %s", resp.Status)
}
var rel Release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, fmt.Errorf("decoding release: %w", err)
}
if rel.Tag == "" {
return nil, fmt.Errorf("release has no tag")
}
return &rel, nil
}
// FindAsset returns the first asset with the given name.
func (r *Release) FindAsset(name string) (Asset, bool) {
for _, a := range r.Assets {
if a.Name == name {
return a, true
}
}
return Asset{}, false
}
// IsNewer reports whether latest is a higher version than current. Versions
// that are not dotted numbers (for example "dev" or a commit hash) are never
// considered outdated, so development builds are not nagged.
func IsNewer(current, latest string) bool {
cur, ok := parseVersion(current)
if !ok {
return false
}
lat, ok := parseVersion(latest)
if !ok {
return false
}
for i := range cur {
if lat[i] != cur[i] {
return lat[i] > cur[i]
}
}
return false
}
// parseVersion parses "v1.2.3" style versions into three numeric parts.
// Missing parts are zero; any pre-release or build suffix is ignored.
func parseVersion(v string) ([3]int, bool) {
var out [3]int
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
if i := strings.IndexAny(v, "-+ "); i >= 0 {
v = v[:i]
}
if v == "" {
return out, false
}
parts := strings.Split(v, ".")
if len(parts) > 3 {
return out, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return out, false
}
out[i] = n
}
return out, true
}
+94
View File
@@ -0,0 +1,94 @@
package updatecheck
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestIsNewer(t *testing.T) {
tests := []struct {
name string
current, latest string
want bool
}{
{"patch bump", "v1.0.85", "v1.0.86", true},
{"minor beats patch", "v1.0.99", "v1.1.0", true},
{"major bump", "v1.9.9", "v2.0.0", true},
{"numeric not lexical", "v1.0.9", "v1.0.10", true},
{"same", "v1.0.85", "v1.0.85", false},
{"older latest", "v1.0.86", "v1.0.85", false},
{"no v prefix", "1.0.1", "v1.0.2", true},
{"short version", "v1.0", "v1.0.1", true},
{"prerelease suffix ignored", "v1.0.1-rc1", "v1.0.2", true},
{"dev build", "dev", "v9.9.9", false},
{"commit hash", "abc1234", "v9.9.9", false},
{"empty current", "", "v1.0.0", false},
{"bad latest", "v1.0.0", "latest", false},
{"too many parts", "v1.0.0.0", "v1.0.1", false},
{"negative", "v1.-1.0", "v1.0.0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsNewer(tt.current, tt.latest); got != tt.want {
t.Errorf("IsNewer(%q,%q) = %v, want %v", tt.current, tt.latest, got, tt.want)
}
})
}
}
func TestLatest(t *testing.T) {
tests := []struct {
name string
status int
body string
wantTag string
wantErr bool
}{
{"ok", 200, `{"tag_name":"v1.2.3","html_url":"https://x/r","assets":[{"name":"a.exe","browser_download_url":"https://x/a.exe"}]}`, "v1.2.3", false},
{"not found", 404, `{}`, "", true},
{"bad json", 200, `{`, "", true},
{"missing tag", 200, `{"html_url":"u"}`, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer srv.Close()
rel, err := Latest(context.Background(), nil, srv.URL)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if err == nil {
if rel.Tag != tt.wantTag {
t.Errorf("tag = %q", rel.Tag)
}
if a, ok := rel.FindAsset("a.exe"); !ok || a.URL != "https://x/a.exe" {
t.Errorf("asset = %+v %v", a, ok)
}
if _, ok := rel.FindAsset("missing"); ok {
t.Error("unexpected asset")
}
}
})
}
}
func TestLatestUnreachable(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
url := srv.URL
srv.Close()
if _, err := Latest(context.Background(), nil, url); err == nil {
t.Error("expected error for unreachable server")
}
}
func TestLatestBadURL(t *testing.T) {
if _, err := Latest(context.Background(), nil, "://bad"); err == nil {
t.Error("expected error for bad URL")
}
}
+29
View File
@@ -0,0 +1,29 @@
# Windows installer
NSIS script: `installer.nsi`. Output: `dist/pgtidy-setup-windows-amd64.exe`.
## Build
```
make installer-windows
```
Needs `makensis` (package `nsis`). CI builds and uploads it on release.
## Installer
- Installs `pgtidy.exe` to `%ProgramFiles%\PgTidy` (admin).
- Adds the install dir to the system `PATH`; removed on uninstall.
- Registers in Add/Remove Programs (supports silent `/S` uninstall).
## Update check
```
pgtidy update # check, prompt, download + run installer (Windows)
pgtidy update --check # report only
pgtidy update --yes # no prompt
```
- Source: latest Gitea release (`pkg/updatecheck`).
- `dev` and commit-hash builds are never reported as outdated.
- Non-Windows: prints the release URL.
+84 -191
View File
@@ -1,35 +1,43 @@
; PgTidy Windows installer
; PgTidy Windows installer (NSIS)
;
; Build with (VERSION must match the release tag, without the leading "v"):
; makensis /DVERSION=1.2.3 /DSRC_EXE=path\to\pgtidy.exe windows\installer.nsi
;
; Installs pgtidy.exe into Program Files, adds the install dir to the
; machine-wide PATH, and on startup checks the git.warky.dev Gitea API for a
; newer release than the one being installed.
; Build: makensis -DVERSION=1.2.3 -DEXE=<abs path to pgtidy.exe> -DOUT=<abs path to installer> windows/installer.nsi
; Use absolute paths for EXE and OUT; relative ones resolve unpredictably.
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!ifndef SRC_EXE
!define SRC_EXE "..\dist\pgtidy.exe"
!ifndef OUT
!define OUT "pgtidy-setup-windows-amd64.exe"
!endif
!ifndef EXE
!define EXE "pgtidy-windows-amd64.exe"
!endif
!define PRODUCT_NAME "PgTidy"
!define PRODUCT_PUBLISHER "Warky Devs"
!define PRODUCT_HOMEPAGE "https://git.warky.dev/wdevs/pgtidy"
!define RELEASES_API_URL "https://git.warky.dev/api/v1/repos/wdevs/pgtidy/releases/latest"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\PgTidy"
!define ENV_KEY 'HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"'
!define APPNAME "PgTidy"
!define REGKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\PgTidy"
!define ENVKEY "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
Unicode true
Name "${APPNAME} ${VERSION}"
OutFile "${OUT}"
InstallDir "$PROGRAMFILES64\${APPNAME}"
InstallDirRegKey HKLM "Software\${APPNAME}" "InstallDir"
RequestExecutionLevel admin
SetCompressor /SOLID lzma
VIProductVersion "${VERSION}.0"
VIAddVersionKey "ProductName" "${APPNAME}"
VIAddVersionKey "FileDescription" "${APPNAME} installer"
VIAddVersionKey "FileVersion" "${VERSION}"
VIAddVersionKey "ProductVersion" "${VERSION}"
VIAddVersionKey "LegalCopyright" "Warky Devs"
!include "MUI2.nsh"
!include "LogicLib.nsh"
Name "${PRODUCT_NAME} ${VERSION}"
OutFile "pgtidy-setup-${VERSION}.exe"
InstallDir "$PROGRAMFILES64\PgTidy"
InstallDirRegKey HKLM "${UNINST_KEY}" "InstallLocation"
RequestExecutionLevel admin
Unicode true
!include "StrFunc.nsh"
!include "WinMessages.nsh"
${StrStr}
${UnStrRep}
!define MUI_ABORTWARNING
!define MUI_ICON "..\assets\logo_128.ico"
@@ -40,187 +48,72 @@ Unicode true
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
; ---------------------------------------------------------------------------
; Check the Gitea releases API for a newer version than the one we are about
; to install. Best-effort only: any failure (offline, API down, no
; PowerShell) is swallowed and the installer proceeds silently.
; ---------------------------------------------------------------------------
Function .onInit
StrCpy $1 "$TEMP\pgtidy-latest-version.txt"
Delete "$1"
DetailPrint "Checking ${PRODUCT_HOMEPAGE} for a newer release..."
nsExec::ExecToLog 'powershell -NoProfile -NonInteractive -Command "try { $$r = Invoke-RestMethod -Uri ''${RELEASES_API_URL}'' -UseBasicParsing -TimeoutSec 5; $$r.tag_name | Out-File -Encoding ascii -NoNewline ''$1'' } catch { exit 0 }"'
${IfNot} ${FileExists} "$1"
Return
${EndIf}
FileOpen $2 "$1" r
FileRead $2 $3
FileClose $2
Delete "$1"
StrCpy $4 $3
; Trim a leading "v" if the tag is e.g. "v1.2.3"
StrCpy $5 $4 1
${If} $5 == "v"
StrCpy $4 $4 "" 1
${EndIf}
${If} $4 != ""
${AndIf} $4 != "${VERSION}"
MessageBox MB_YESNO|MB_ICONINFORMATION \
"A newer version of PgTidy is available: $4 (this installer is ${VERSION}).$\n$\nOpen the releases page to download it now?$\n$\nChoosing No continues installing ${VERSION}." \
IDNO +2
ExecShell "open" "${PRODUCT_HOMEPAGE}/releases/latest"
${EndIf}
FunctionEnd
; ---------------------------------------------------------------------------
; Adds $INSTDIR to the machine PATH if it isn't already present.
; ---------------------------------------------------------------------------
Function AddToPath
ReadRegStr $0 ${ENV_KEY} "Path"
Push "$0"
Push "$INSTDIR"
Call StrContains
Pop $1
${If} $1 == ""
${If} $0 == ""
StrCpy $0 "$INSTDIR"
${Else}
StrCpy $0 "$0;$INSTDIR"
${EndIf}
WriteRegExpandStr ${ENV_KEY} "Path" "$0"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
${EndIf}
FunctionEnd
; ---------------------------------------------------------------------------
; Removes $INSTDIR from the machine PATH.
; ---------------------------------------------------------------------------
Function un.RemoveFromPath
ReadRegStr $0 ${ENV_KEY} "Path"
Push "$0;"
Push "$INSTDIR;"
Push ""
Call un.StrReplace
Pop $0
Push "$0"
Push "$INSTDIR"
Push ""
Call un.StrReplace
Pop $0
; Drop a trailing separator left behind by the replacements above.
StrCpy $1 $0 1 -1
${If} $1 == ";"
StrCpy $0 $0 -1
${EndIf}
WriteRegExpandStr ${ENV_KEY} "Path" "$0"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
FunctionEnd
; Returns the index of needle in haystack via $R0, or "" if absent.
; Push haystack, Push needle -> Pop result
Function StrContains
Exch $R1 ; needle
Exch
Exch $R2 ; haystack
Push $R3
Push $R4
Push $R5
StrLen $R3 $R1
StrCpy $R4 0
${Do}
StrCpy $R5 $R2 $R3 $R4
${If} $R5 == $R1
StrCpy $R0 $R4
${ExitDo}
${EndIf}
${If} $R5 == ""
StrCpy $R0 ""
${ExitDo}
${EndIf}
IntOp $R4 $R4 + 1
${Loop}
Pop $R5
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Push $R0
Exch
Pop $R0
FunctionEnd
; Push string, Push search, Push replace -> Pop result
Function un.StrReplace
Exch $R0 ; replace
Exch
Exch $R1 ; search
Exch 2
Exch $R2 ; string
Push $R3
Push $R4
Push $R5
Push $R6
StrLen $R3 $R1
StrCpy $R4 ""
${Do}
StrCpy $R5 $R2 $R3
${If} $R5 == $R1
StrCpy $R4 "$R4$R0"
StrCpy $R2 $R2 "" $R3
${ElseIf} $R2 == ""
${ExitDo}
${Else}
StrCpy $R6 $R2 1
StrCpy $R4 "$R4$R6"
StrCpy $R2 $R2 "" 1
${EndIf}
${Loop}
Pop $R6
Pop $R5
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Pop $R0
Push $R4
FunctionEnd
Section "PgTidy" SEC_MAIN
SectionIn RO
Section "Install"
SetRegView 64
SetOutPath "$INSTDIR"
File "${SRC_EXE}"
File "/oname=pgtidy.exe" "${EXE}"
File "..\LICENSE"
Call AddToPath
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${PRODUCT_PUBLISHER}"
WriteRegStr HKLM "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$INSTDIR\uninstall.exe /S"
WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1
WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1
WriteUninstaller "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\${APPNAME}" "InstallDir" "$INSTDIR"
WriteRegStr HKLM "${REGKEY}" "DisplayName" "${APPNAME}"
WriteRegStr HKLM "${REGKEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${REGKEY}" "Publisher" "Warky Devs"
WriteRegStr HKLM "${REGKEY}" "DisplayIcon" "$INSTDIR\pgtidy.exe"
WriteRegStr HKLM "${REGKEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "${REGKEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKLM "${REGKEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S'
WriteRegDWORD HKLM "${REGKEY}" "NoModify" 1
WriteRegDWORD HKLM "${REGKEY}" "NoRepair" 1
; Add the install directory to the system PATH unless it is already there.
ReadRegStr $0 HKLM "${ENVKEY}" "Path"
${StrStr} $1 ";$0;" ";$INSTDIR;"
${If} $1 == ""
StrLen $2 $0
${If} $2 > 900
; NSIS strings are limited; rewriting a very long PATH could truncate it.
MessageBox MB_OK|MB_ICONEXCLAMATION "PATH is too long to update automatically. Add $INSTDIR to PATH manually."
${Else}
${If} $0 == ""
WriteRegExpandStr HKLM "${ENVKEY}" "Path" "$INSTDIR"
${Else}
WriteRegExpandStr HKLM "${ENVKEY}" "Path" "$0;$INSTDIR"
${EndIf}
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
${EndIf}
${EndIf}
SectionEnd
Section "Uninstall"
Call un.RemoveFromPath
SetRegView 64
Delete "$INSTDIR\pgtidy.exe"
Delete "$INSTDIR\LICENSE"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
DeleteRegKey HKLM "${UNINST_KEY}"
; Remove the install directory from the system PATH.
ReadRegStr $0 HKLM "${ENVKEY}" "Path"
; Wrap in separators so every entry, including the first and last, matches.
StrCpy $1 ";$0;"
${UnStrRep} $1 "$1" ";$INSTDIR;" ";"
StrCpy $2 $1 1
${If} $2 == ";"
StrCpy $1 $1 "" 1
${EndIf}
StrCpy $2 $1 1 -1
${If} $2 == ";"
StrCpy $1 $1 -1
${EndIf}
${If} $1 != $0
WriteRegExpandStr HKLM "${ENVKEY}" "Path" "$1"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
${EndIf}
DeleteRegKey HKLM "${REGKEY}"
DeleteRegKey HKLM "Software\${APPNAME}"
SectionEnd