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:
dhkatzandClaude Opus 5.5 committed 2026-10-27 05:03:47 -07:00
1 parent 5519f00d71
commit bd7272f5eb
27 files changed
+1089 -59

No files matched your search

+8 -4
View File
@@ -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: |
+1
View File
@@ -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
+14
View File
@@ -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

+10 -37
View File
@@ -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()
}
+164
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
/manifest.json
+2
View File
@@ -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>`.
+6
View File
@@ -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 }
+13
View File
@@ -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
}
+332
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 &amp; 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>
+176
View File
@@ -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; }
+141
View File
@@ -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)
}
+30
View File
@@ -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
)
+69
View File
@@ -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=
+13 -2
View File
@@ -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
}
+3 -3
View File
@@ -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" {
+12 -3
View File
@@ -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
}
+13 -5
View File
@@ -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
+13 -3
View File
@@ -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)
+31
View File
@@ -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,6 +1,6 @@
//go:build !windows
package main
package install
import "os"
@@ -1,6 +1,6 @@
//go:build windows
package main
package install
import (
"fmt"
+1
View File
@@ -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")
}