feat: 1.1.1.2 hybrid Go installer (online 5.8M/offline 98M, выбор C:\ZernMC + ярлык ZernMC Launcher.lnk, uninstall.exe, DataDir ~/.zernmc) — hollow-ромбы, custom-сборки, моды с правами, ПКМ без Reload, sticky Играть, онбординг Config persist + /launcher/download/jre
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
module zernmc-installer
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac h1:/QqP+ajFMma4hNWQyBDVaQQhz9Z1kDyXScNWMO3owx0=
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
|
||||
@@ -0,0 +1,552 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sqweek/dialog"
|
||||
)
|
||||
|
||||
var (
|
||||
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
|
||||
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
|
||||
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from adjacent zip)")
|
||||
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
|
||||
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultInstallDir = "C:\\ZernMC"
|
||||
serverBase = "https://api.zern.cc"
|
||||
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Offline flag overrides online
|
||||
isOnline := *onlineFlag && !*offlineFlag
|
||||
|
||||
dir := *installDirFlag
|
||||
if dir == "" {
|
||||
dir = askInstallDir()
|
||||
}
|
||||
if dir == "" {
|
||||
fmt.Println("Installation cancelled")
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline)
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fatal("Cannot create directory: %v", err)
|
||||
}
|
||||
|
||||
createShortcut := *shortcutFlag
|
||||
if !isFlagSet("shortcut") {
|
||||
// ask via dialog if not explicitly flagged
|
||||
createShortcut = askShortcut()
|
||||
}
|
||||
|
||||
if isOnline {
|
||||
if err := installOnline(dir, createShortcut); err != nil {
|
||||
fatal("Online install failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
zipPath := *offlineZipFlag
|
||||
if zipPath == "" {
|
||||
// try to find adjacent offline zip
|
||||
zipPath = findAdjacentOfflineZip()
|
||||
if zipPath == "" {
|
||||
// ask user
|
||||
zipPath, _ = dialog.File().Title("Select ZernMC-Offline.zip").Filter("ZIP (*.zip)", "zip").Load()
|
||||
}
|
||||
}
|
||||
if zipPath == "" {
|
||||
fatal("Offline zip not specified")
|
||||
}
|
||||
if err := installOffline(dir, zipPath, createShortcut); err != nil {
|
||||
fatal("Offline install failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nInstallation complete!")
|
||||
fmt.Printf("Launcher: %s\\zernmc.exe\n", dir)
|
||||
fmt.Printf("Data dir: %s\\.zernmc (local files, instances)\n", os.Getenv("USERPROFILE"))
|
||||
if createShortcut {
|
||||
fmt.Println("Shortcut on desktop: ZernMC Launcher.lnk")
|
||||
}
|
||||
fmt.Println("Uninstall: run", filepath.Join(dir, "uninstall.exe"), "or delete folder")
|
||||
}
|
||||
|
||||
func isFlagSet(name string) bool {
|
||||
found := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func askInstallDir() string {
|
||||
// Try GUI dialog first (Windows)
|
||||
if runtime.GOOS == "windows" {
|
||||
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
|
||||
if err == nil && dir != "" {
|
||||
// dialog.Directory returns chosen dir; append ZernMC if not already
|
||||
if !strings.Contains(strings.ToLower(dir), "zernmc") {
|
||||
dir = filepath.Join(dir, "ZernMC")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
// fallback to default if dialog fails/cancelled with empty
|
||||
fmt.Printf("Dialog cancelled or error (%v), using default %s\n", err, defaultInstallDir)
|
||||
}
|
||||
// CLI fallback
|
||||
fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
input = defaultInstallDir
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func askShortcut() bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
// simple yes/no dialog
|
||||
// sqweek/dialog Message does not return bool, so use question
|
||||
// fallback to true by default if dialog unavailable
|
||||
yes := dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
|
||||
return yes
|
||||
}
|
||||
|
||||
func fatal(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
// also show dialog on Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error()
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func installOnline(dir string, createShortcut bool) error {
|
||||
fmt.Println("Online installation — downloading JRE + launcher files...")
|
||||
// Step 1: download JRE zip if missing
|
||||
jreDir := filepath.Join(dir, "lib", "jre21")
|
||||
javaExe := filepath.Join(jreDir, "bin", "java.exe")
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
fmt.Println("Downloading JRE 21 (47 MB)...")
|
||||
jreURL := serverBase + "/launcher/download/jre"
|
||||
tmpZip := filepath.Join(os.TempDir(), jreZipName)
|
||||
if err := downloadFile(jreURL, tmpZip); err != nil {
|
||||
// fallback to direct zip from builds if server endpoint not ready
|
||||
// try alternative mirrors
|
||||
mirrors := []string{
|
||||
"https://api.zernmc.ru/launcher/download/jre",
|
||||
"https://api.zernmc.online/launcher/download/jre",
|
||||
}
|
||||
var lastErr error
|
||||
for _, m := range mirrors {
|
||||
fmt.Printf("Retry mirror %s\n", m)
|
||||
if err := downloadFile(m, tmpZip); err == nil {
|
||||
lastErr = nil
|
||||
break
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("JRE download failed: %w", lastErr)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
|
||||
if err := unzip(tmpZip, dir); err != nil {
|
||||
// try alt: unzip should contain jre21/ prefix or lib/jre21?
|
||||
// our zip contains jre21/ at root, need to move to lib/jre21
|
||||
return fmt.Errorf("unzip JRE: %w", err)
|
||||
}
|
||||
// handle both zip layouts: some zips contain jre21/, some lib/jre21/
|
||||
// if tmp unzip created dir/jre21, move to lib/jre21
|
||||
candidate := filepath.Join(dir, "jre21")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
os.MkdirAll(filepath.Join(dir, "lib"), 0755)
|
||||
os.RemoveAll(jreDir)
|
||||
if err := os.Rename(candidate, jreDir); err != nil {
|
||||
// fallback copy
|
||||
if err := copyDir(candidate, jreDir); err != nil {
|
||||
return err
|
||||
}
|
||||
os.RemoveAll(candidate)
|
||||
}
|
||||
}
|
||||
os.Remove(tmpZip)
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
return fmt.Errorf("JRE unpack failed, %s not found", javaExe)
|
||||
}
|
||||
fmt.Println("JRE ready")
|
||||
} else {
|
||||
fmt.Println("JRE already present, skipping")
|
||||
}
|
||||
|
||||
// Step 2: fetch meta and download launcher files
|
||||
fmt.Println("Fetching launcher meta...")
|
||||
meta, err := fetchMeta()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch meta: %w", err)
|
||||
}
|
||||
fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
|
||||
|
||||
for i, f := range meta.Files {
|
||||
rel := f.Path
|
||||
// skip JRE entries (should not be in meta, but just in case)
|
||||
if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") {
|
||||
continue
|
||||
}
|
||||
dest := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
need := true
|
||||
if fi, err := os.Stat(dest); err == nil && !fi.IsDir() {
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash == strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
need = false
|
||||
}
|
||||
}
|
||||
if !need {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
|
||||
// try mirrors on failure
|
||||
if err := downloadFile(url, dest); err != nil {
|
||||
// try versioned fallback (some servers use builds/ vs versions/)
|
||||
alt := fmt.Sprintf("%s/launcher/download/jar", serverBase) // not generic
|
||||
_ = alt
|
||||
return fmt.Errorf("download %s: %w", rel, err)
|
||||
}
|
||||
// verify hash
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash != strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
fmt.Printf(" WARNING hash mismatch for %s\n", rel)
|
||||
}
|
||||
}
|
||||
|
||||
// write build.version
|
||||
if err := os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err != nil {
|
||||
fmt.Printf("warning write build.version: %v\n", err)
|
||||
}
|
||||
// also ensure bin/zernmclauncher.jar etc are present
|
||||
fmt.Println("Launcher files ready")
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installOffline(dir, zipPath string, createShortcut bool) error {
|
||||
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
|
||||
if _, err := os.Stat(zipPath); err != nil {
|
||||
return fmt.Errorf("offline zip not found: %s", zipPath)
|
||||
}
|
||||
fmt.Println("Unpacking (98 MB, ~200 MB unpacked)...")
|
||||
if err := unzip(zipPath, dir); err != nil {
|
||||
return fmt.Errorf("unzip: %w", err)
|
||||
}
|
||||
// zip contains zernmc.exe, lib/, bin/, assets/ at root — already correct relative to dir
|
||||
// if zip contained top-level folder, handle it
|
||||
// we unzip directly to dir, so should be fine
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
fmt.Println("Offline unpack complete")
|
||||
fmt.Printf("Data will be in %s\\.zernmc\n", os.Getenv("USERPROFILE"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
// support resume? simple GET
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// ensure parent
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
total := resp.ContentLength
|
||||
var written int64
|
||||
buf := make([]byte, 64*1024)
|
||||
start := time.Now()
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
written += int64(n)
|
||||
if total > 0 {
|
||||
pct := float64(written) / float64(total) * 100
|
||||
elapsed := time.Since(start).Seconds()
|
||||
speed := float64(written) / 1024 / 1024 / (elapsed + 0.001)
|
||||
fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
|
||||
} else {
|
||||
fmt.Printf("\r %.1f MB ", float64(written)/1024/1024)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
out.Close()
|
||||
// verify tmp exists
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
// fallback copy
|
||||
if err2 := copyFile(tmp, dest); err2 != nil {
|
||||
return err2
|
||||
}
|
||||
os.Remove(tmp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzip(src, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
for _, f := range r.File {
|
||||
fpath := filepath.Join(dest, f.Name)
|
||||
// ZipSlip protection
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("illegal file path: %s", fpath)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, f.Mode())
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(path, src)
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
} `json:"files"`
|
||||
}
|
||||
|
||||
func fetchMeta() (*Meta, error) {
|
||||
// try /launcher/meta/{version} flow: first get version, then meta
|
||||
resp, err := http.Get(serverBase + "/launcher/version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var verResp struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver := verResp.Version
|
||||
if ver == "" {
|
||||
ver = "1.1.1"
|
||||
}
|
||||
metaResp, err := http.Get(serverBase + "/launcher/meta/" + ver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer metaResp.Body.Close()
|
||||
if metaResp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("meta %d", metaResp.StatusCode)
|
||||
}
|
||||
var meta Meta
|
||||
if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.Version == "" {
|
||||
meta.Version = ver
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func findAdjacentOfflineZip() string {
|
||||
exe, _ := os.Executable()
|
||||
dir := filepath.Dir(exe)
|
||||
// look for ZernMC-Offline*.zip or ZernMC-win*.zip nearby
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
matches, _ = filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
// also check current dir
|
||||
matches, _ = filepath.Glob("ZernMC-Offline*.zip")
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func createDesktopShortcut(installDir string) error {
|
||||
if runtime.GOOS != "windows" {
|
||||
return nil
|
||||
}
|
||||
desktop := filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
|
||||
if _, err := os.Stat(desktop); err != nil {
|
||||
desktop = filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop")
|
||||
}
|
||||
if _, err := os.Stat(desktop); err != nil {
|
||||
return fmt.Errorf("desktop not found")
|
||||
}
|
||||
lnk := filepath.Join(desktop, "ZernMC Launcher.lnk")
|
||||
target := filepath.Join(installDir, "zernmc.exe")
|
||||
ps := fmt.Sprintf(`$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WorkingDirectory = '%s'; $Shortcut.IconLocation = '%s'; $Shortcut.Save()`, lnk, target, installDir, target)
|
||||
tmp := filepath.Join(os.TempDir(), "zernmc_shortcut.ps1")
|
||||
if err := os.WriteFile(tmp, []byte(ps), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func createUninstaller(dir string) error {
|
||||
// create uninstall.bat and copy self as uninstall.exe
|
||||
exe, _ := os.Executable()
|
||||
uninstExe := filepath.Join(dir, "uninstall.exe")
|
||||
if exe != "" && exe != uninstExe {
|
||||
_ = copyFile(exe, uninstExe)
|
||||
}
|
||||
bat := filepath.Join(dir, "uninstall.bat")
|
||||
content := fmt.Sprintf(`@echo off
|
||||
echo Uninstalling ZernMC from %s
|
||||
timeout /t 2 >nul
|
||||
del "%%USERPROFILE%%\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
del "%%USERPROFILE%%\OneDrive\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
echo Data in %%USERPROFILE%%\.zernmc will be kept.
|
||||
echo To remove completely, delete %%USERPROFILE%%\.zernmc manually.
|
||||
`, dir)
|
||||
_ = os.WriteFile(bat, []byte(content), 0644)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCmd(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
Reference in New Issue
Block a user