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]>
213 lines
6.2 KiB
Go
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
|
|
}
|