Files
t5de/patcher/internal/install/install.go
T
dhkatzandClaude Opus 5.5 bd7272f5eb Add the setup wizard
cmd/t5de-setup is the installer users will run: a Wails window with a plain HTML/CSS/JS page that
walks through finding and checking IMVU Classic, choosing patches (required ones locked; turning a
patch on turns on what it needs), options and installing, with progress. When T5DE is already
installed, or when run from the copy kept in the install, it offers changing patches, repair or
update, and uninstall; "Apps & features" opens its uninstall page. It runs from a temporary copy
when it would otherwise change its own folder.

For it, apply and install report progress, the bundle can be loaded from bytes (setup carries it
built in), the System reports the registered install, and the relaunch logic moved from the
command-line patcher into install. The T5DE logo and icon are in patcher/assets.

CI vets the wizard and cross-compiles it for Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:03:47 -07:00

213 lines
6.2 KiB
Go

// Package install installs patched clients for the current user, without admin rights: the patched copy, a copy of
// the setup and its bundle (to modify or uninstall it later), shortcuts, and an "Apps & features" entry.
package install
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.t5de.dev/t5de/t5de/patcher/internal/apply"
"git.t5de.dev/t5de/t5de/patcher/internal/bundle"
)
const (
// SetupFile and BundleFile are the copies of the setup and its bundle kept in an install.
SetupFile = "t5de-setup.exe"
BundleFile = "t5de-bundle.json"
// ShortcutFile is the shortcut's name in the Start menu and on the desktop.
ShortcutFile = "T5DE.lnk"
clientExe = "IMVUClient.exe"
)
// System is what installing needs from the operating system (system_windows.go); tests use a fake.
type System interface {
FindClient() (string, error) // the installed IMVU Classic folder
ProgramsDir() (string, error) // the Start menu's programs folder
DesktopDir() (string, error)
CreateShortcut(link, target, workDir, description string) error
Register(Registration) error // the "Apps & features" entry
Unregister() error
RemoveSettings() error // the patched client's settings (HKCU\Software\T5DE)
DataDir() string // the patched client's user data (%APPDATA%\T5DE)
InstallLocation() string // the registered install's folder, or ""
}
// Registration is the "Apps & features" entry for an install.
type Registration struct {
DisplayName string
DisplayVersion string
Publisher string
InstallLocation string
DisplayIcon string
UninstallString string
EstimatedSizeKB uint32
}
// Options for Install.
type Options struct {
Manifest *bundle.Manifest
ManifestJSON []byte // the bundle, kept in the install to modify it later
Selected []*bundle.Patch
Client string // the IMVU Classic folder; empty to find it
Dir string // where to install; empty for DefaultDir
Desktop bool // also put a shortcut on the desktop
Setup string // the setup executable to copy into the install
Progress apply.Progress
}
// DefaultDir is where clients are installed by default.
func DefaultDir() string {
return filepath.Join(os.Getenv("LOCALAPPDATA"), "T5DEClient")
}
// Install patches a copy of the client into the install folder, replacing an earlier install there, and adds
// shortcuts and the "Apps & features" entry. It returns the install folder.
func Install(sys System, o Options) (string, error) {
dir := o.Dir
if dir == "" {
dir = DefaultDir()
}
dir, err := filepath.Abs(dir)
if err != nil {
return "", err
}
if Inside(o.Setup, dir) {
return "", errors.New("can't install over the folder the setup runs from; run a copy from elsewhere")
}
client := o.Client
if client == "" {
if client, err = sys.FindClient(); err != nil {
return "", err
}
}
progress := o.Progress
if progress == nil {
progress = func(string) {}
}
if err := apply.Apply(o.Manifest, o.Selected, client, dir, progress); err != nil {
return "", err
}
progress("Creating shortcuts")
if err := copyFile(o.Setup, filepath.Join(dir, SetupFile)); err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(dir, BundleFile), o.ManifestJSON, 0o644); err != nil {
return "", err
}
record, err := apply.ReadRecord(dir)
if err != nil {
return "", err
}
folders := []func() (string, error){sys.ProgramsDir}
if o.Desktop {
folders = append(folders, sys.DesktopDir)
}
description := "IMVU Classic " + o.Manifest.Client.Version + ", patched by t5de"
for _, folder := range folders {
location, err := folder()
if err != nil {
return "", err
}
link := filepath.Join(location, ShortcutFile)
if err := sys.CreateShortcut(link, filepath.Join(dir, clientExe), dir, description); err != nil {
return "", fmt.Errorf("creating %s: %w", link, err)
}
record.Shortcuts = append(record.Shortcuts, link)
}
if err := apply.WriteRecord(dir, record); err != nil {
return "", err
}
progress("Registering with Windows")
size, err := dirSize(dir)
if err != nil {
return "", err
}
setup := filepath.Join(dir, SetupFile)
return dir, sys.Register(Registration{
DisplayName: "T5DE (IMVU Classic " + o.Manifest.Client.Version + ")",
DisplayVersion: o.Manifest.Client.Version,
Publisher: "t5de",
InstallLocation: dir,
DisplayIcon: filepath.Join(dir, clientExe),
UninstallString: fmt.Sprintf(`"%s" uninstall -dir "%s"`, setup, dir),
EstimatedSizeKB: uint32((size + 1023) / 1024),
})
}
// Uninstall removes an install: its shortcuts, its "Apps & features" entry and its folder, and with removeData
// also the patched client's settings and user data. Run it from outside dir.
func Uninstall(sys System, dir string, removeData bool) error {
record, err := apply.ReadRecord(dir)
if err != nil {
return err
}
for _, link := range record.Shortcuts {
if err := os.Remove(link); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if err := sys.Unregister(); err != nil {
return err
}
// The setup that started this uninstall may still be exiting, holding its file in dir open.
for attempt := 0; ; attempt++ {
err = os.RemoveAll(dir)
if err == nil || attempt == 40 {
break
}
time.Sleep(250 * time.Millisecond)
}
if err != nil {
return fmt.Errorf("removing %s (is the client still running?): %w", dir, err)
}
if removeData {
if err := sys.RemoveSettings(); err != nil {
return err
}
return os.RemoveAll(sys.DataDir())
}
return nil
}
// Inside reports whether path is inside dir.
func Inside(path, dir string) bool {
rel, err := filepath.Rel(dir, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) &&
!filepath.IsAbs(rel)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
func dirSize(dir string) (int64, error) {
var size int64
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
size += info.Size()
}
return err
})
return size, err
}