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]>
This commit is contained in:
1 parent
5519f00d71
commit
bd7272f5eb
27 files changed
+1089
-59
No files matched your search
@@ -26,16 +26,20 @@ jobs:
|
||||
go-version-file: patcher/go.mod
|
||||
cache-dependency-path: patcher/go.sum
|
||||
|
||||
# Windows-only code (registry, shortcuts) can't be tested here: it's vetted and built, and tested on Windows.
|
||||
# Windows-only code (registry, shortcuts, the setup wizard) can't be tested here: it's vetted and built for
|
||||
# Windows, and tested on Windows. The wizard (Wails) needs its build tags.
|
||||
- name: Check the patcher
|
||||
working-directory: patcher
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then echo "Not gofmt-formatted: $unformatted"; exit 1; fi
|
||||
go vet ./...
|
||||
GOOS=windows go vet ./...
|
||||
go test ./...
|
||||
portable=$(go list ./... | grep -v /cmd/t5de-setup)
|
||||
go vet $portable
|
||||
go test $portable
|
||||
GOOS=windows go vet -tags desktop,production ./...
|
||||
GOOS=windows GOARCH=amd64 go build -o "$RUNNER_TEMP/t5de-patcher.exe" ./cmd/t5de-patcher
|
||||
GOOS=windows GOARCH=amd64 go build -tags desktop,production -ldflags "-s -w -H=windowsgui" \
|
||||
-o "$RUNNER_TEMP/t5de-setup.exe" ./cmd/t5de-setup
|
||||
|
||||
- name: Install a C compiler and 7-Zip
|
||||
run: |
|
||||
|
||||
@@ -17,3 +17,4 @@ __pycache__/
|
||||
# Go patcher binaries from `go build` in patcher/
|
||||
/patcher/t5de-patcher
|
||||
/patcher/t5de-patcher.exe
|
||||
/patcher/cmd/t5de-setup/t5de-setup.exe
|
||||
@@ -0,0 +1,14 @@
|
||||
// Package assets holds T5DE's logo and icon.
|
||||
package assets
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Logo is the T5DE logo, a 256x256 PNG.
|
||||
//
|
||||
//go:embed t5de.png
|
||||
var Logo []byte
|
||||
|
||||
// Icon is the Windows icon, 16 to 256 pixels.
|
||||
//
|
||||
//go:embed t5de.ico
|
||||
var Icon []byte
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -15,9 +15,7 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -105,7 +103,7 @@ func applyCommand(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apply.Apply(m, selected, *client, *out); err != nil {
|
||||
if err := apply.Apply(m, selected, *client, *out, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Patched IMVU Classic %s into %s with %s\n", m.Client.Version, *out,
|
||||
@@ -129,7 +127,11 @@ func installCommand(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
installed, err := install.Install(install.NewSystem(), install.Options{Manifest: m, ManifestPath: *manifestPath,
|
||||
manifestJSON, err := os.ReadFile(*manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
installed, err := install.Install(install.NewSystem(), install.Options{Manifest: m, ManifestJSON: manifestJSON,
|
||||
Selected: selected, Client: *client, Dir: *dir, Desktop: *desktop, Setup: setup})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -149,43 +151,14 @@ func uninstallCommand(args []string) error {
|
||||
return err
|
||||
}
|
||||
// Windows can't delete a running program, so when run from the install (as "Apps & features" does), continue
|
||||
// from a copy in the temporary folder, which waits for this process to exit.
|
||||
// from a temporary copy.
|
||||
if exe, err := os.Executable(); err == nil && install.Inside(exe, target) {
|
||||
return relaunch(exe, append([]string{"uninstall"}, args...))
|
||||
return install.Relaunch(exe, append([]string{"uninstall"}, args...))
|
||||
}
|
||||
err = install.Uninstall(install.NewSystem(), target, *removeData)
|
||||
if exe, exeErr := os.Executable(); exeErr == nil && strings.HasPrefix(filepath.Base(exe), relaunchPrefix) {
|
||||
deleteAfterExit(exe) // this is the temporary copy relaunch made
|
||||
}
|
||||
if err != nil {
|
||||
defer install.Cleanup()
|
||||
if err := install.Uninstall(install.NewSystem(), target, *removeData); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Uninstalled T5DE from %s\n", target)
|
||||
return nil
|
||||
}
|
||||
|
||||
// relaunchPrefix names the temporary copies relaunch makes.
|
||||
const relaunchPrefix = "t5de-uninstall-"
|
||||
|
||||
func relaunch(exe string, args []string) error {
|
||||
copyPath := filepath.Join(os.TempDir(), fmt.Sprintf("%s%d.exe", relaunchPrefix, os.Getpid()))
|
||||
in, err := os.Open(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(copyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(copyPath, args...)
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"git.t5de.dev/t5de/t5de/patcher/internal/apply"
|
||||
"git.t5de.dev/t5de/t5de/patcher/internal/bundle"
|
||||
"git.t5de.dev/t5de/t5de/patcher/internal/install"
|
||||
)
|
||||
|
||||
// App is the wizard's back end; its exported methods are callable from the page (window.go.main.App).
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
sys install.System
|
||||
manifest *bundle.Manifest
|
||||
manifestJSON []byte
|
||||
bundleError string
|
||||
mode string // install, manage or uninstall
|
||||
dir string // the install to manage or uninstall
|
||||
}
|
||||
|
||||
// PatchInfo describes a patch for the page.
|
||||
type PatchInfo struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Group string `json:"group"`
|
||||
Required bool `json:"required"`
|
||||
Depends []string `json:"depends"`
|
||||
}
|
||||
|
||||
// State is what the page starts from.
|
||||
type State struct {
|
||||
Mode string `json:"mode"`
|
||||
Version string `json:"version"` // the IMVU Classic release this setup patches
|
||||
Patches []PatchInfo `json:"patches"`
|
||||
DefaultDir string `json:"defaultDir"`
|
||||
BundleError string `json:"bundleError"`
|
||||
InstalledDir string `json:"installedDir"`
|
||||
InstalledVersion string `json:"installedVersion"`
|
||||
InstalledPatches []string `json:"installedPatches"`
|
||||
}
|
||||
|
||||
// Client is a found IMVU Classic folder, or why there isn't a usable one.
|
||||
type Client struct {
|
||||
Dir string `json:"dir"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// InstallRequest is what the user chose.
|
||||
type InstallRequest struct {
|
||||
Client string `json:"client"`
|
||||
Dir string `json:"dir"`
|
||||
Patches []string `json:"patches"` // optional patches; required ones are always applied
|
||||
Desktop bool `json:"desktop"`
|
||||
}
|
||||
|
||||
func (a *App) startup(ctx context.Context) { a.ctx = ctx }
|
||||
|
||||
func (a *App) State() State {
|
||||
s := State{Mode: a.mode, DefaultDir: install.DefaultDir(), BundleError: a.bundleError, InstalledDir: a.dir}
|
||||
if a.manifest != nil {
|
||||
s.Version = a.manifest.Client.Version
|
||||
for _, p := range a.manifest.Patches {
|
||||
s.Patches = append(s.Patches, PatchInfo{p.ID, p.Title, p.Description, p.Group, p.Required, p.Depends})
|
||||
}
|
||||
}
|
||||
if a.dir != "" {
|
||||
if record, err := apply.ReadRecord(a.dir); err == nil {
|
||||
s.InstalledVersion, s.InstalledPatches = record.Client, record.Patches
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// FindClient finds IMVU Classic and checks it's the release this setup patches.
|
||||
func (a *App) FindClient() Client {
|
||||
dir, err := a.sys.FindClient()
|
||||
if err != nil {
|
||||
return Client{Error: err.Error()}
|
||||
}
|
||||
return Client{Dir: dir, Error: a.CheckClient(dir)}
|
||||
}
|
||||
|
||||
// CheckClient checks a folder holds an intact copy of the IMVU Classic release this setup patches.
|
||||
func (a *App) CheckClient(dir string) string {
|
||||
if a.manifest == nil {
|
||||
return a.bundleError
|
||||
}
|
||||
if err := apply.Verify(a.manifest, dir); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ChooseFolder asks the user for a folder, starting at current; it returns "" if they cancel.
|
||||
func (a *App) ChooseFolder(title, current string) string {
|
||||
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: title, DefaultDirectory: current})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// Install installs (or reinstalls) T5DE, sending "progress" events; it returns an error message or "".
|
||||
func (a *App) Install(req InstallRequest) string {
|
||||
if a.manifest == nil {
|
||||
return a.bundleError
|
||||
}
|
||||
selected, err := a.manifest.Select(req.Patches)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
setup, err := os.Executable()
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
_, err = install.Install(a.sys, install.Options{
|
||||
Manifest: a.manifest, ManifestJSON: a.manifestJSON, Selected: selected, Client: req.Client, Dir: req.Dir,
|
||||
Desktop: req.Desktop, Setup: setup,
|
||||
Progress: func(step string) { runtime.EventsEmit(a.ctx, "progress", step) },
|
||||
})
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
a.dir = req.Dir
|
||||
return ""
|
||||
}
|
||||
|
||||
// Uninstall removes the install being managed; it returns an error message or "".
|
||||
func (a *App) Uninstall(removeData bool) string {
|
||||
runtime.EventsEmit(a.ctx, "progress", "Removing T5DE")
|
||||
if err := install.Uninstall(a.sys, a.dir, removeData); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Launch starts the installed client and closes the setup.
|
||||
func (a *App) Launch() string {
|
||||
cmd := exec.Command(filepath.Join(a.dir, "IMVUClient.exe"))
|
||||
cmd.Dir = a.dir
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
runtime.Quit(a.ctx)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) Quit() { runtime.Quit(a.ctx) }
|
||||
|
||||
// SelfTest reports whether the setup runs as a self-test (T5DE_SETUP_SELFTEST names a report file): the page then
|
||||
// reports what it rendered with SelfTestDone and the setup closes.
|
||||
func (a *App) SelfTest() bool { return os.Getenv("T5DE_SETUP_SELFTEST") != "" }
|
||||
|
||||
func (a *App) SelfTestDone(report string) {
|
||||
os.WriteFile(os.Getenv("T5DE_SETUP_SELFTEST"), []byte(report), 0o644)
|
||||
runtime.Quit(a.ctx)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/manifest.json
|
||||
@@ -0,0 +1,2 @@
|
||||
Release builds copy the patch bundle here as `manifest.json` (from `t5de bundle`), and `t5de-setup` embeds it.
|
||||
Without it, run `t5de-setup -bundle <manifest.json>`.
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// showError has no window to show outside Windows; the error goes to stderr.
|
||||
func showError(string) bool { return false }
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// showError shows a message box.
|
||||
func showError(message string) bool {
|
||||
text, _ := windows.UTF16PtrFromString(message)
|
||||
title, _ := windows.UTF16PtrFromString("T5DE Setup")
|
||||
windows.MessageBox(0, text, title, windows.MB_OK|windows.MB_ICONERROR)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use strict";
|
||||
|
||||
// The setup's pages. Go methods are on window.go.main.App (app.go); "progress" events report install steps.
|
||||
const go = () => window.go.main.App;
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const escape = (text) => String(text ?? "").replace(/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||
|
||||
const GROUPS = [
|
||||
{ title: "Always included", groups: ["core", "privacy"] },
|
||||
{ title: "Features", groups: ["features"] },
|
||||
{ title: "Annoyances", groups: ["annoyances"] },
|
||||
];
|
||||
const INSTALL_STEPS = 7; // reported by Install (internal/install, internal/apply)
|
||||
|
||||
let state;
|
||||
let client = { dir: "", error: "" };
|
||||
const choice = { patches: new Set(), dir: "", desktop: false };
|
||||
let flow = []; // page names, in order
|
||||
let index = 0;
|
||||
let result = { error: "", kind: "" };
|
||||
|
||||
const pages = {
|
||||
welcome: {
|
||||
step: "Welcome",
|
||||
render: () => `
|
||||
<h1>Install T5DE</h1>
|
||||
<p class="lead">For IMVU Classic ${escape(state.version)}</p>
|
||||
<p>T5DE makes a patched copy of IMVU Classic with extra features and privacy fixes: it stops IMVU's
|
||||
telemetry and crash reports, and adds things like more room filters and photo mode in the shop.</p>
|
||||
<p>Your normal IMVU stays exactly as it is. T5DE is installed next to it, keeps its own settings, and both can
|
||||
be used.</p>
|
||||
<p class="muted small">T5DE is unofficial and isn't made by or affiliated with IMVU. It runs with your IMVU
|
||||
account, so use it at your own risk.</p>`,
|
||||
},
|
||||
|
||||
client: {
|
||||
step: "IMVU Classic",
|
||||
enter: async () => { client = await go().FindClient(); },
|
||||
render: () => `
|
||||
<h1>Find IMVU Classic</h1>
|
||||
<p class="lead">T5DE patches a copy of your own IMVU Classic ${escape(state.version)}.</p>
|
||||
${client.dir && !client.error ? `
|
||||
<div class="card status good"><span class="icon">✓</span><div class="grow">
|
||||
<div><b>Found IMVU Classic ${escape(state.version)}</b></div>
|
||||
<div class="path">${escape(client.dir)}</div></div></div>` : `
|
||||
<div class="card status bad"><span class="icon">✕</span><div class="grow">
|
||||
${client.dir ? `<div class="path">${escape(client.dir)}</div>` : ""}
|
||||
<div class="error">${escape(client.error)}</div>
|
||||
<p class="muted small" style="margin-top:8px">Install or update IMVU Classic from imvu.com, start it once,
|
||||
and try again, or choose the folder it's installed in.</p></div></div>`}
|
||||
<button class="secondary" id="choose-client">Choose folder…</button>`,
|
||||
bind: () => {
|
||||
$("#choose-client").onclick = async () => {
|
||||
const dir = await go().ChooseFolder("Choose the IMVU Classic folder", client.dir);
|
||||
if (dir) {
|
||||
client = { dir, error: await go().CheckClient(dir) };
|
||||
show();
|
||||
}
|
||||
};
|
||||
},
|
||||
valid: () => client.dir && !client.error,
|
||||
},
|
||||
|
||||
patches: {
|
||||
step: "Patches",
|
||||
render: () => `
|
||||
<h1>Choose patches</h1>
|
||||
<p class="lead">Turn on what you'd like. You can change this later by running the setup again.</p>
|
||||
${GROUPS.map((g) => {
|
||||
const patches = state.patches.filter((p) => g.groups.includes(p.group));
|
||||
return patches.length ? `<div class="group"><h2>${g.title}</h2>${patches.map(patchRow).join("")}</div>` : "";
|
||||
}).join("")}`,
|
||||
bind: () => {
|
||||
for (const box of document.querySelectorAll("input[data-patch]")) {
|
||||
box.onchange = () => { toggle(box.dataset.patch, box.checked); show(); };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
options: {
|
||||
step: "Options",
|
||||
render: () => `
|
||||
<h1>Options</h1>
|
||||
<p class="lead">Where to put T5DE, and how to start it.</p>
|
||||
<div class="card">
|
||||
<div class="muted small">Install folder</div>
|
||||
<div class="row"><span class="path">${escape(choice.dir)}</span>
|
||||
${action === "install" ? '<button class="secondary" id="choose-dir">Change…</button>' : ""}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="muted small">A Start menu shortcut is always added.</div>
|
||||
<label class="option"><input type="checkbox" id="desktop" ${choice.desktop ? "checked" : ""}>
|
||||
Also add a desktop shortcut</label>
|
||||
</div>`,
|
||||
bind: () => {
|
||||
const chooser = $("#choose-dir");
|
||||
if (chooser) {
|
||||
chooser.onclick = async () => {
|
||||
const dir = await go().ChooseFolder("Choose where to install T5DE", choice.dir);
|
||||
if (dir) { choice.dir = dir.replace(/[\\/]+$/, "") + "\\T5DEClient"; show(); }
|
||||
};
|
||||
}
|
||||
$("#desktop").onchange = (e) => { choice.desktop = e.target.checked; };
|
||||
},
|
||||
nextLabel: () => (action === "install" ? "Install" : "Apply"),
|
||||
},
|
||||
|
||||
manage: {
|
||||
step: "Manage",
|
||||
render: () => {
|
||||
const update = state.installedVersion && state.installedVersion !== state.version;
|
||||
return `
|
||||
<h1>T5DE is installed</h1>
|
||||
<p class="lead">IMVU Classic ${escape(state.installedVersion)}, in <span class="path">${escape(state.installedDir)}</span></p>
|
||||
<div class="choices">
|
||||
<button class="choice" data-action="change">Change patches<span>Turn patches on or off.</span></button>
|
||||
<button class="choice" data-action="repair">${update ? `Update to IMVU Classic ${escape(state.version)}` : "Repair"}
|
||||
<span>${update ? "Rebuild T5DE for the new IMVU Classic, with the same patches." :
|
||||
"Rebuild T5DE from your IMVU Classic, with the same patches."}</span></button>
|
||||
<button class="choice" data-action="uninstall">Uninstall<span>Remove T5DE. Your normal IMVU isn't affected.</span></button>
|
||||
</div>`;
|
||||
},
|
||||
bind: () => {
|
||||
for (const b of document.querySelectorAll("button[data-action]")) {
|
||||
b.onclick = () => startAction(b.dataset.action);
|
||||
}
|
||||
},
|
||||
hideNext: true,
|
||||
},
|
||||
|
||||
uninstall: {
|
||||
step: "Uninstall",
|
||||
render: () => `
|
||||
<h1>Uninstall T5DE</h1>
|
||||
<p class="lead">From <span class="path">${escape(state.installedDir)}</span></p>
|
||||
<p>This removes T5DE, its shortcuts and its entry in Apps & features. Your normal IMVU isn't affected.</p>
|
||||
<div class="card"><label class="option"><input type="checkbox" id="remove-data">
|
||||
Also remove T5DE's settings and logs</label></div>`,
|
||||
nextLabel: () => "Uninstall",
|
||||
nextClass: "danger",
|
||||
},
|
||||
|
||||
progress: {
|
||||
step: () => (action === "uninstall" ? "Removing" : "Installing"),
|
||||
render: () => `
|
||||
<h1>${action === "uninstall" ? "Removing T5DE" : "Installing T5DE"}</h1>
|
||||
<p class="lead" id="current">Starting…</p>
|
||||
<div class="bar ${action === "uninstall" ? "indeterminate" : ""}"><div id="bar"></div></div>
|
||||
<ul id="log"></ul>`,
|
||||
enter: () => setTimeout(run, 0),
|
||||
busy: true,
|
||||
},
|
||||
|
||||
finish: {
|
||||
step: "Done",
|
||||
render: () => result.error ? `
|
||||
<h1>Something went wrong</h1>
|
||||
<p class="lead">${action === "uninstall" ? "T5DE couldn't be removed." : "T5DE couldn't be installed."}</p>
|
||||
<div class="card"><div class="error">${escape(result.error)}</div></div>` : action === "uninstall" ? `
|
||||
<h1>T5DE was removed</h1>
|
||||
<p class="lead">Your normal IMVU is still installed.</p>` : `
|
||||
<h1>T5DE is ready</h1>
|
||||
<p class="lead">Start it from the Start menu${choice.desktop ? " or your desktop" : ""}.</p>
|
||||
<p>The first time, log in again: T5DE keeps its own settings, separate from your normal IMVU.</p>
|
||||
<div class="card"><label class="option"><input type="checkbox" id="launch" checked> Start T5DE now</label></div>`,
|
||||
nextLabel: () => "Finish",
|
||||
hideBack: () => !result.error,
|
||||
hideCancel: true,
|
||||
},
|
||||
|
||||
failed: {
|
||||
step: "Setup",
|
||||
render: () => `<h1>T5DE Setup can't continue</h1><div class="card"><div class="error">${escape(state.bundleError)}</div></div>`,
|
||||
nextLabel: () => "Close",
|
||||
hideBack: () => true,
|
||||
hideCancel: true,
|
||||
},
|
||||
};
|
||||
|
||||
let action = "install"; // install, change, repair or uninstall
|
||||
|
||||
function patchRow(p) {
|
||||
const on = p.required || choice.patches.has(p.id);
|
||||
return `<label class="patch ${p.required ? "locked" : ""}">
|
||||
<input type="checkbox" data-patch="${escape(p.id)}" ${on ? "checked" : ""} ${p.required ? "disabled" : ""}>
|
||||
<div><div class="title">${escape(p.title)}${p.required ? '<span class="badge">Required</span>' : ""}</div>
|
||||
${p.description ? `<div class="description">${escape(p.description)}</div>` : ""}</div></label>`;
|
||||
}
|
||||
|
||||
// Turning a patch on turns on what it needs; turning one off turns off what needs it.
|
||||
function toggle(id, on) {
|
||||
const byId = new Map(state.patches.map((p) => [p.id, p]));
|
||||
if (on) {
|
||||
const pending = [id];
|
||||
while (pending.length) {
|
||||
const p = byId.get(pending.pop());
|
||||
if (p && !choice.patches.has(p.id)) { choice.patches.add(p.id); pending.push(...(p.depends || [])); }
|
||||
}
|
||||
} else {
|
||||
const pending = [id];
|
||||
while (pending.length) {
|
||||
const removed = pending.pop();
|
||||
choice.patches.delete(removed);
|
||||
for (const p of state.patches) {
|
||||
if (choice.patches.has(p.id) && (p.depends || []).includes(removed)) pending.push(p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startAction(name) {
|
||||
action = name;
|
||||
const installed = new Set(state.installedPatches || []);
|
||||
choice.patches = new Set(state.patches.filter((p) => !p.required && installed.has(p.id)).map((p) => p.id));
|
||||
choice.dir = state.installedDir;
|
||||
flow = { change: ["manage", "client", "patches", "options", "progress", "finish"],
|
||||
repair: ["manage", "client", "progress", "finish"],
|
||||
uninstall: ["manage", "uninstall", "progress", "finish"] }[name];
|
||||
go_to(1);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const step = (message) => {
|
||||
const current = $("#current");
|
||||
if (!current) return;
|
||||
if (current.dataset.started) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = current.textContent;
|
||||
$("#log").appendChild(item);
|
||||
}
|
||||
current.dataset.started = "1";
|
||||
current.textContent = message;
|
||||
const done = $("#log").children.length;
|
||||
$("#bar").style.width = `${Math.round((100 * (done + 0.5)) / INSTALL_STEPS)}%`;
|
||||
};
|
||||
window.runtime.EventsOn("progress", step);
|
||||
let error;
|
||||
if (action === "uninstall") {
|
||||
error = await go().Uninstall(removeData);
|
||||
} else {
|
||||
error = await go().Install({ client: client.dir, dir: choice.dir, patches: [...choice.patches],
|
||||
desktop: choice.desktop });
|
||||
}
|
||||
window.runtime.EventsOff("progress");
|
||||
result = { error };
|
||||
go_to(flow.indexOf("finish"));
|
||||
}
|
||||
|
||||
let removeData = false;
|
||||
|
||||
async function next() {
|
||||
const page = pages[flow[index]];
|
||||
if (page.valid && !page.valid()) return;
|
||||
if (flow[index] === "uninstall") removeData = $("#remove-data").checked;
|
||||
if (flow[index] === "finish" || flow[index] === "failed") {
|
||||
if (flow[index] === "finish" && !result.error && action !== "uninstall" && $("#launch")?.checked) {
|
||||
const error = await go().Launch();
|
||||
if (!error) return;
|
||||
}
|
||||
go().Quit();
|
||||
return;
|
||||
}
|
||||
go_to(index + 1);
|
||||
}
|
||||
|
||||
async function go_to(i) {
|
||||
index = i;
|
||||
const page = pages[flow[index]];
|
||||
if (page.enter) await page.enter();
|
||||
show();
|
||||
}
|
||||
|
||||
function show() {
|
||||
const name = flow[index];
|
||||
const page = pages[name];
|
||||
$("#page").innerHTML = page.render();
|
||||
if (page.bind) page.bind();
|
||||
|
||||
const visible = flow.filter((n) => n !== "manage" || flow[0] === "manage");
|
||||
$("#steps").innerHTML = visible.map((n) => {
|
||||
const at = flow.indexOf(n);
|
||||
const step = typeof pages[n].step === "function" ? pages[n].step() : pages[n].step;
|
||||
return `<li class="${at < index ? "done" : at === index ? "current" : ""}">${escape(step)}</li>`;
|
||||
}).join("");
|
||||
|
||||
const nextButton = $("#next");
|
||||
nextButton.textContent = page.nextLabel ? page.nextLabel() : "Next";
|
||||
nextButton.className = page.nextClass || "";
|
||||
nextButton.hidden = !!page.hideNext;
|
||||
nextButton.disabled = !!page.busy || (page.valid ? !page.valid() : false);
|
||||
$("#back").hidden = index === 0 || !!page.busy || (page.hideBack ? page.hideBack() : false) ||
|
||||
(name === "finish" && result.error && action === "uninstall");
|
||||
$("#cancel").hidden = !!page.busy || !!page.hideCancel;
|
||||
}
|
||||
|
||||
$("#next").onclick = next;
|
||||
$("#back").onclick = () => {
|
||||
if (flow[index] === "finish") { go_to(flow.indexOf("progress") - 1); return; }
|
||||
go_to(index - 1);
|
||||
};
|
||||
$("#cancel").onclick = () => go().Quit();
|
||||
|
||||
async function start() {
|
||||
state = await go().State();
|
||||
if (state.bundleError) {
|
||||
flow = ["failed"];
|
||||
} else if (state.mode === "uninstall") {
|
||||
action = "uninstall";
|
||||
flow = ["uninstall", "progress", "finish"];
|
||||
} else if (state.mode === "manage") {
|
||||
flow = ["manage"];
|
||||
} else {
|
||||
choice.dir = state.defaultDir;
|
||||
choice.patches = new Set(state.patches.filter((p) => !p.required).map((p) => p.id));
|
||||
flow = ["welcome", "client", "patches", "options", "progress", "finish"];
|
||||
}
|
||||
await go_to(0);
|
||||
|
||||
if (await go().SelfTest()) {
|
||||
const report = { mode: state.mode, version: state.version, patches: state.patches?.length ?? 0,
|
||||
bundleError: state.bundleError, heading: $("h1")?.textContent, steps: $("#steps").children.length };
|
||||
if (flow[0] === "welcome") {
|
||||
await go_to(flow.indexOf("patches"));
|
||||
report.patchCheckboxes = document.querySelectorAll("input[data-patch]").length;
|
||||
report.lockedPatches = document.querySelectorAll("input[data-patch]:disabled").length;
|
||||
}
|
||||
go().SelfTestDone(JSON.stringify(report));
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>T5DE Setup</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside>
|
||||
<img class="logo" src="/logo.png" alt="T5DE">
|
||||
<ol id="steps"></ol>
|
||||
</aside>
|
||||
<main>
|
||||
<section id="page"></section>
|
||||
<footer>
|
||||
<button id="back" class="secondary">Back</button>
|
||||
<span class="spacer"></span>
|
||||
<button id="cancel" class="secondary">Cancel</button>
|
||||
<button id="next">Next</button>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,176 @@
|
||||
:root {
|
||||
--bg: #f6f6f8;
|
||||
--panel: #ffffff;
|
||||
--sidebar: #eceaf1;
|
||||
--text: #1d1b22;
|
||||
--muted: #64606d;
|
||||
--border: #dddae3;
|
||||
--accent: #6d4aff;
|
||||
--accent-text: #ffffff;
|
||||
--good: #1f8a4c;
|
||||
--bad: #c62f3e;
|
||||
--danger: #c62f3e;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #17161b;
|
||||
--panel: #1f1d24;
|
||||
--sidebar: #131217;
|
||||
--text: #edebf2;
|
||||
--muted: #a19cab;
|
||||
--border: #34313b;
|
||||
--accent: #8b6dff;
|
||||
--good: #4fc27f;
|
||||
--bad: #f0616f;
|
||||
--danger: #e0485a;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
font: 14px/1.45 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body { display: flex; }
|
||||
|
||||
aside {
|
||||
width: 230px;
|
||||
flex: none;
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 28px 22px;
|
||||
}
|
||||
|
||||
.logo { width: 112px; height: 112px; display: block; margin: 0 auto 26px; }
|
||||
|
||||
#steps { list-style: none; margin: 0; padding: 0; counter-reset: step; }
|
||||
|
||||
#steps li {
|
||||
counter-increment: step;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#steps li::before {
|
||||
content: counter(step);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
#steps li.done::before { content: "✓"; border-color: var(--accent); color: var(--accent); }
|
||||
#steps li.current { color: var(--text); font-weight: 600; }
|
||||
#steps li.current::before { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
|
||||
main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
#page { flex: 1; padding: 30px 34px 10px; overflow-y: auto; user-select: text; }
|
||||
|
||||
h1 { font-size: 22px; font-weight: 600; margin: 0 0 6px; }
|
||||
.lead { color: var(--muted); margin: 0 0 22px; }
|
||||
p { margin: 0 0 12px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12.5px; }
|
||||
code, .path { font-family: "Cascadia Mono", Consolas, monospace; font-size: 12.5px; word-break: break-all; }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.status { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.status .icon { font-size: 20px; line-height: 1.2; }
|
||||
.good .icon { color: var(--good); }
|
||||
.bad .icon { color: var(--bad); }
|
||||
.status .grow { flex: 1; min-width: 0; }
|
||||
|
||||
.group { margin-bottom: 18px; }
|
||||
.group h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 0 8px; }
|
||||
|
||||
label.patch {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
label.patch.locked { cursor: default; opacity: .85; }
|
||||
label.patch .title { font-weight: 600; }
|
||||
label.patch .badge { font-size: 11px; color: var(--muted); border: 1px solid var(--border); border-radius: 99px; padding: 0 7px; margin-left: 6px; font-weight: 400; }
|
||||
label.patch .description { color: var(--muted); font-size: 12.5px; margin-top: 2px; white-space: pre-line; }
|
||||
|
||||
input[type=checkbox] { width: 16px; height: 16px; margin: 3px 0 0; accent-color: var(--accent); flex: none; }
|
||||
|
||||
.option { display: flex; gap: 10px; align-items: center; margin: 10px 0; }
|
||||
.row { display: flex; gap: 10px; align-items: center; }
|
||||
.row .path { flex: 1; }
|
||||
|
||||
.choices { display: grid; gap: 10px; }
|
||||
button.choice {
|
||||
text-align: left;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.choice span { display: block; color: var(--muted); font-weight: 400; font-size: 12.5px; margin-top: 2px; }
|
||||
button.choice:hover { border-color: var(--accent); }
|
||||
|
||||
.bar { height: 8px; background: var(--border); border-radius: 99px; overflow: hidden; margin: 18px 0 14px; }
|
||||
.bar div { height: 100%; width: 0; background: var(--accent); border-radius: 99px; transition: width .25s; }
|
||||
.bar.indeterminate div { width: 35%; animation: slide 1.1s ease-in-out infinite; }
|
||||
@keyframes slide { from { margin-left: -35%; } to { margin-left: 100%; } }
|
||||
#log { list-style: none; padding: 0; margin: 0; color: var(--muted); font-size: 12.5px; }
|
||||
#log li::before { content: "✓ "; color: var(--accent); }
|
||||
|
||||
.error { color: var(--bad); white-space: pre-wrap; }
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px 34px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 8px 18px;
|
||||
min-width: 92px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
||||
button.danger { background: var(--danger); color: #fff; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
button[hidden] { display: none; }
|
||||
@@ -0,0 +1,141 @@
|
||||
// Command t5de-setup is T5DE's installer: a wizard that patches a copy of the user's own IMVU Classic with the
|
||||
// patches they choose, using the bundle built into it.
|
||||
//
|
||||
// t5de-setup install, or manage an existing install
|
||||
// t5de-setup manage -dir <folder> modify, repair or uninstall the install in folder
|
||||
// t5de-setup uninstall -dir <folder>
|
||||
//
|
||||
// -bundle manifest.json uses that bundle instead of the built-in one (for development). Build with
|
||||
// `go build -tags desktop,production -ldflags -H=windowsgui`; release builds first copy the bundle to
|
||||
// bundle/manifest.json.
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
|
||||
"git.t5de.dev/t5de/t5de/patcher/assets"
|
||||
"git.t5de.dev/t5de/t5de/patcher/internal/apply"
|
||||
"git.t5de.dev/t5de/t5de/patcher/internal/bundle"
|
||||
"git.t5de.dev/t5de/t5de/patcher/internal/install"
|
||||
)
|
||||
|
||||
//go:embed all:frontend
|
||||
var frontend embed.FS
|
||||
|
||||
// The bundle a release build carries; bundle/manifest.json is copied in by the release build.
|
||||
//
|
||||
//go:embed bundle
|
||||
var embedded embed.FS
|
||||
|
||||
func main() {
|
||||
app := &App{sys: install.NewSystem(), mode: "install"}
|
||||
args := os.Args[1:]
|
||||
if len(args) > 0 && (args[0] == "manage" || args[0] == "uninstall") {
|
||||
app.mode, args = args[0], args[1:]
|
||||
}
|
||||
flags := flag.NewFlagSet("t5de-setup", flag.ExitOnError)
|
||||
dir := flags.String("dir", "", "the install to manage or uninstall")
|
||||
bundlePath := flags.String("bundle", "", "use this bundle's manifest.json instead of the built-in one")
|
||||
flags.Parse(args)
|
||||
|
||||
exe, _ := os.Executable()
|
||||
switch {
|
||||
case app.mode != "install":
|
||||
app.dir, _ = filepath.Abs(*dir)
|
||||
case isInstall(filepath.Dir(exe)):
|
||||
// The copy kept in an install: manage that install.
|
||||
app.mode, app.dir = "manage", filepath.Dir(exe)
|
||||
case isInstall(app.sys.InstallLocation()):
|
||||
app.mode, app.dir = "manage", app.sys.InstallLocation()
|
||||
}
|
||||
// Changing or removing the install this program runs from needs a copy running from elsewhere.
|
||||
if app.dir != "" && install.Inside(exe, app.dir) {
|
||||
relaunchArgs := []string{app.mode, "-dir", app.dir}
|
||||
if *bundlePath != "" {
|
||||
relaunchArgs = append(relaunchArgs, "-bundle", *bundlePath)
|
||||
}
|
||||
if err := install.Relaunch(exe, relaunchArgs); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer install.Cleanup()
|
||||
|
||||
app.manifestJSON, app.manifest, app.bundleError = loadBundle(*bundlePath, app.dir)
|
||||
pages, _ := fs.Sub(frontend, "frontend")
|
||||
err := wails.Run(&options.App{
|
||||
Title: "T5DE Setup",
|
||||
Width: 860,
|
||||
Height: 580,
|
||||
DisableResize: true,
|
||||
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0},
|
||||
AssetServer: &assetserver.Options{Assets: pages, Handler: http.HandlerFunc(serveLogo)},
|
||||
OnStartup: app.startup,
|
||||
Bind: []interface{}{app},
|
||||
Windows: &windows.Options{Theme: windows.SystemDefault, WebviewIsTransparent: false},
|
||||
})
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func isInstall(dir string) bool {
|
||||
if dir == "" {
|
||||
return false
|
||||
}
|
||||
_, err := apply.ReadRecord(dir)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// loadBundle reads the bundle: -bundle, else the built-in one, else the one kept in the install being managed.
|
||||
func loadBundle(path, installDir string) ([]byte, *bundle.Manifest, string) {
|
||||
var data []byte
|
||||
var err error
|
||||
switch {
|
||||
case path != "":
|
||||
data, err = os.ReadFile(path)
|
||||
default:
|
||||
data, err = embedded.ReadFile("bundle/manifest.json")
|
||||
if errors.Is(err, fs.ErrNotExist) && installDir != "" {
|
||||
data, err = os.ReadFile(filepath.Join(installDir, install.BundleFile))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, "This setup has no patches built in: " + err.Error()
|
||||
}
|
||||
m, err := bundle.Parse(data)
|
||||
if err != nil {
|
||||
return nil, nil, "This setup's patches can't be read: " + err.Error()
|
||||
}
|
||||
return data, m, ""
|
||||
}
|
||||
|
||||
func serveLogo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/logo.png" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write(assets.Logo)
|
||||
}
|
||||
|
||||
// fail reports an error before the window exists: there's no console for a GUI program.
|
||||
func fail(err error) {
|
||||
message := fmt.Sprintf("T5DE Setup couldn't start: %v", err)
|
||||
if !showError(message) {
|
||||
fmt.Fprintln(os.Stderr, message)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -4,5 +4,35 @@ go 1.27
|
||||
|
||||
require (
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/wailsapp/wails/v2 v2.17.0
|
||||
golang.org/x/sys v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.15.3 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
)
|
||||
@@ -1,5 +1,74 @@
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E=
|
||||
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.15.3 h1:lIdG4kK5RdMyhwCwSc4AmSQsLBb3AVwok6S8PX/9kwQ=
|
||||
github.com/labstack/echo/v4 v4.15.3/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/wails/v2 v2.17.0 h1:pJqX9ogqW4PPE/gSeCQUcYmpPghxEpHajONw9rPj1pg=
|
||||
github.com/wailsapp/wails/v2 v2.17.0/go.mod h1:WUqCTXDAPqkcYhjUawowutjzCEgx6Ri8P51E9SozpZw=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -63,9 +63,16 @@ func WriteRecord(dir string, r *InstallRecord) error {
|
||||
return os.WriteFile(filepath.Join(dir, Record), append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// Progress is told about each step as it starts.
|
||||
type Progress func(step string)
|
||||
|
||||
// Apply writes the client in clientDir, patched with the selected patches, to outDir. outDir must not exist, or
|
||||
// be a client the patcher wrote (which is replaced).
|
||||
func Apply(m *bundle.Manifest, selected []*bundle.Patch, clientDir, outDir string) error {
|
||||
// be a client the patcher wrote (which is replaced). progress may be nil.
|
||||
func Apply(m *bundle.Manifest, selected []*bundle.Patch, clientDir, outDir string, progress Progress) error {
|
||||
if progress == nil {
|
||||
progress = func(string) {}
|
||||
}
|
||||
progress("Checking IMVU Classic")
|
||||
if err := Verify(m, clientDir); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -76,6 +83,7 @@ func Apply(m *bundle.Manifest, selected []*bundle.Patch, clientDir, outDir strin
|
||||
for _, name := range m.Core.Remove {
|
||||
removed[name] = true
|
||||
}
|
||||
progress("Copying IMVU Classic")
|
||||
if err := copyTree(clientDir, outDir, removed); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,9 +92,11 @@ func Apply(m *bundle.Manifest, selected []*bundle.Patch, clientDir, outDir strin
|
||||
return err
|
||||
}
|
||||
}
|
||||
progress("Patching the client's code")
|
||||
if err := spliceLibrary(m, selected, filepath.Join(clientDir, library), filepath.Join(outDir, library)); err != nil {
|
||||
return err
|
||||
}
|
||||
progress("Patching the interface")
|
||||
if err := patchContent(selected, filepath.Join(clientDir, filepath.FromSlash(jar)),
|
||||
filepath.Join(outDir, filepath.FromSlash(jar))); err != nil {
|
||||
return err
|
||||
@@ -96,6 +106,7 @@ func Apply(m *bundle.Manifest, selected []*bundle.Patch, clientDir, outDir strin
|
||||
return err
|
||||
}
|
||||
}
|
||||
progress("Updating checksums")
|
||||
if err := writeChecksums(outDir, removed); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestApply(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "out")
|
||||
if err := Apply(m, selected, client, out); err != nil {
|
||||
if err := Apply(m, selected, client, out, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestApply(t *testing.T) {
|
||||
t.Errorf("%v: record %s", c.ids, data)
|
||||
}
|
||||
// Applying again replaces the patched client.
|
||||
if err := Apply(m, selected, client, out); err != nil {
|
||||
if err := Apply(m, selected, client, out, nil); err != nil {
|
||||
t.Errorf("%v: reapplying: %v", c.ids, err)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func TestApplyRefusesOtherDirectories(t *testing.T) {
|
||||
selected, _ := m.Select(nil)
|
||||
out := t.TempDir() // exists, and isn't a patched client
|
||||
os.WriteFile(filepath.Join(out, "important.txt"), []byte("keep me"), 0o644)
|
||||
if err := Apply(m, selected, client, out); err == nil {
|
||||
if err := Apply(m, selected, client, out, nil); err == nil {
|
||||
t.Fatal("no error")
|
||||
}
|
||||
if data, _ := os.ReadFile(filepath.Join(out, "important.txt")); string(data) != "keep me" {
|
||||
|
||||
@@ -87,12 +87,21 @@ func Load(path string) (*Manifest, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
m, err := Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Parse reads and checks a manifest.
|
||||
func Parse(data []byte) (*Manifest, error) {
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Format != Format {
|
||||
return nil, fmt.Errorf("%s: bundle format %d, but this patcher reads format %d", path, m.Format, Format)
|
||||
return nil, fmt.Errorf("bundle format %d, but this patcher reads format %d", m.Format, Format)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
@@ -32,8 +32,9 @@ type System interface {
|
||||
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)
|
||||
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.
|
||||
@@ -50,12 +51,13 @@ type Registration struct {
|
||||
// Options for Install.
|
||||
type Options struct {
|
||||
Manifest *bundle.Manifest
|
||||
ManifestPath string // copied into the install, to modify it later
|
||||
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.
|
||||
@@ -83,13 +85,18 @@ func Install(sys System, o Options) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := apply.Apply(o.Manifest, o.Selected, client, dir); err != nil {
|
||||
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 := copyFile(o.ManifestPath, filepath.Join(dir, BundleFile)); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, BundleFile), o.ManifestJSON, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -117,6 +124,7 @@ func Install(sys System, o Options) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
progress("Registering with Windows")
|
||||
size, err := dirSize(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -28,6 +28,12 @@ func (s *fakeSystem) Register(r Registration) error { s.registration = &r; retur
|
||||
func (s *fakeSystem) Unregister() error { s.registration = nil; return nil }
|
||||
func (s *fakeSystem) RemoveSettings() error { s.settingsRemoved = true; return nil }
|
||||
func (s *fakeSystem) DataDir() string { return s.data }
|
||||
func (s *fakeSystem) InstallLocation() string {
|
||||
if s.registration == nil {
|
||||
return ""
|
||||
}
|
||||
return s.registration.InstallLocation
|
||||
}
|
||||
|
||||
func setup(t *testing.T) (*fakeSystem, Options) {
|
||||
t.Helper()
|
||||
@@ -38,23 +44,27 @@ func setup(t *testing.T) (*fakeSystem, Options) {
|
||||
for _, d := range []string{sys.programs, sys.desktop, sys.data} {
|
||||
os.MkdirAll(d, 0o755)
|
||||
}
|
||||
setupExe, manifest := filepath.Join(root, "setup.exe"), filepath.Join(root, "manifest.json")
|
||||
setupExe := filepath.Join(root, "setup.exe")
|
||||
os.WriteFile(setupExe, []byte("MZ setup"), 0o644)
|
||||
os.WriteFile(manifest, []byte(`{"format": 1}`), 0o644)
|
||||
selected, err := m.Select(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sys, Options{Manifest: m, ManifestPath: manifest, Selected: selected, Dir: filepath.Join(root, "T5DEClient"),
|
||||
return sys, Options{Manifest: m, ManifestJSON: []byte(`{"format": 1}`), Selected: selected, Dir: filepath.Join(root, "T5DEClient"),
|
||||
Desktop: true, Setup: setupExe}
|
||||
}
|
||||
|
||||
func TestInstallAndUninstall(t *testing.T) {
|
||||
sys, opts := setup(t)
|
||||
var steps []string
|
||||
opts.Progress = func(step string) { steps = append(steps, step) }
|
||||
dir, err := Install(sys, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(steps) != 7 || sys.InstallLocation() != dir {
|
||||
t.Errorf("steps %q, install location %q", steps, sys.InstallLocation())
|
||||
}
|
||||
for _, name := range []string{SetupFile, BundleFile, apply.Record, "IMVUClient.exe", "library.zip"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
||||
t.Errorf("%s missing: %v", name, err)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// relaunchPrefix names the temporary copies Relaunch makes.
|
||||
const relaunchPrefix = "t5de-setup-tmp-"
|
||||
|
||||
// Relaunch starts a copy of exe in the temporary folder with args, for changing or removing the install exe runs
|
||||
// from: Windows can't delete or replace a running program. The copy deletes itself when done (see Cleanup).
|
||||
func Relaunch(exe string, args []string) error {
|
||||
copyPath := filepath.Join(os.TempDir(), fmt.Sprintf("%s%d.exe", relaunchPrefix, os.Getpid()))
|
||||
if err := copyFile(exe, copyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(copyPath, args...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// Cleanup deletes the running program after it exits if it's a copy Relaunch made. Call it before exiting.
|
||||
func Cleanup() {
|
||||
if exe, err := os.Executable(); err == nil && strings.HasPrefix(filepath.Base(exe), relaunchPrefix) {
|
||||
deleteAfterExit(exe)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
package install
|
||||
|
||||
import "os"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
package install
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -22,4 +22,5 @@ func (unsupported) CreateShortcut(link, target, dir, desc string) error { return
|
||||
func (unsupported) Register(Registration) error { return errUnsupported }
|
||||
func (unsupported) Unregister() error { return errUnsupported }
|
||||
func (unsupported) RemoveSettings() error { return errUnsupported }
|
||||
func (unsupported) InstallLocation() string { return "" }
|
||||
func (unsupported) DataDir() string { return filepath.Join(os.TempDir(), "unsupported") }
|
||||
@@ -126,6 +126,16 @@ func (s *windowsSystem) RemoveSettings() error {
|
||||
return deleteTree(registry.CURRENT_USER, s.settingsKey)
|
||||
}
|
||||
|
||||
func (s *windowsSystem) InstallLocation() string {
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER, uninstallKeys+s.uninstallKey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer key.Close()
|
||||
dir, _, _ := key.GetStringValue("InstallLocation")
|
||||
return dir
|
||||
}
|
||||
|
||||
func (s *windowsSystem) DataDir() string {
|
||||
return filepath.Join(os.Getenv("APPDATA"), "T5DE")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user