This commit is contained in:
2025-07-08 14:54:41 +07:00
commit 644a6f9803
86 changed files with 12422 additions and 0 deletions

142
internal/fs-service.go Normal file
View File

@@ -0,0 +1,142 @@
package internal
import (
"firefly-launcher/pkg/sevenzip"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
"golang.org/x/sys/windows"
)
type FSService struct{}
func (f *FSService) PickFolder() (string, error) {
dialog := application.OpenFileDialog().
CanChooseDirectories(true).
CanCreateDirectories(true).
ResolvesAliases(true)
if runtime.GOOS == "darwin" {
dialog.SetMessage("Select a file/directory")
} else {
dialog.SetTitle("Select a file/directory")
}
if path, err := dialog.PromptForSingleSelection(); err == nil {
return path, nil
}
return "", nil
}
func (f *FSService) PickFile() (string, error) {
dialog := application.OpenFileDialog().
CanChooseFiles(true).
ResolvesAliases(true)
if runtime.GOOS == "darwin" {
dialog.SetMessage("Select a file/directory")
} else {
dialog.SetTitle("Select a file/directory")
}
if path, err := dialog.PromptForSingleSelection(); err == nil {
return path, nil
}
return "", nil
}
func (f *FSService) DirExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
func (f *FSService) FileExists(path string) bool {
if info, err := os.Stat(path); err == nil {
return info.Mode().IsRegular()
}
return false
}
func (f *FSService) StartApp(path string) (bool, error) {
cmd := exec.Command(path)
err := cmd.Start()
if err != nil {
return false, err
}
if strings.HasSuffix(path, "StarRail.exe") {
go func() {
_ = cmd.Wait()
application.Get().EmitEvent("game:exit")
}()
}
return true, nil
}
func (f *FSService) StartWithConsole(path string) (bool, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return false, err
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
return false, fmt.Errorf("file not found: %s", absPath)
}
cmd := exec.Command(absPath)
cmd.Dir = filepath.Dir(absPath)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &windows.SysProcAttr{
CreationFlags: windows.CREATE_NEW_CONSOLE |
windows.CREATE_BREAKAWAY_FROM_JOB,
NoInheritHandles: true,
}
err = cmd.Start()
if err != nil {
return false, err
}
go func() {
_ = cmd.Wait()
if strings.HasSuffix(path, "launcher.exe") {
application.Get().EmitEvent("game:exit")
} else if strings.HasSuffix(path, "firefly-go_win.exe") {
application.Get().EmitEvent("server:exit")
} else if strings.HasSuffix(path, "FireflyProxy.exe") {
application.Get().EmitEvent("proxy:exit")
}
}()
return true, nil
}
func (f *FSService) OpenFolder(path string) (bool, string) {
absPath, err := filepath.Abs(path)
if err != nil {
return false, "failed to resolve absolute path: " + err.Error()
}
if !f.DirExists(absPath) {
return false, "directory not found: " + absPath
}
url := "file:///" + filepath.ToSlash(absPath)
application.Get().BrowserOpenURL(url)
return true, ""
}
func (f *FSService) FileExistsInZip(archivePath, fileInside string) (bool, string) {
exists, err := sevenzip.IsFileIn7z(archivePath, fileInside)
if err != nil {
return false, err.Error()
}
return exists, ""
}

203
internal/git-service.go Normal file
View File

@@ -0,0 +1,203 @@
package internal
import (
"encoding/json"
"firefly-launcher/pkg/constant"
"firefly-launcher/pkg/models"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"github.com/wailsapp/wails/v3/pkg/application"
)
type GitService struct{}
func (g *GitService) GetLatestServerVersion(oldVersion string) (bool, string, string) {
resp, err := http.Get(constant.ServerGitUrl)
if err != nil {
return false, "", err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, "", err.Error()
}
if len(releases) == 0 {
return false, "", "no releases found"
}
return true, releases[0].TagName, ""
}
func (g *GitService) DownloadServerProgress(version string) (bool, string) {
resp, err := http.Get(constant.ServerGitUrl)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []*models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, err.Error()
}
if len(releases) == 0 {
return false, "no releases found"
}
var releaseData *models.ReleaseType
for _, release := range releases {
if release.TagName == version {
releaseData = release
break
}
}
if releaseData == nil || releaseData.TagName == "" {
return false, "no release found"
}
var assetWin models.AssetType
for _, asset := range releaseData.Assets {
if asset.Name == constant.ServerZipFile {
assetWin = asset
break
}
}
if assetWin.Name == "" {
return false, "no assets found"
}
if err := os.Mkdir(constant.ServerStorageUrl, 0755); err != nil {
if !os.IsExist(err) {
return false, err.Error()
}
}
saveFile := filepath.Join(constant.ServerStorageUrl, assetWin.Name)
resp, err = http.Get(assetWin.BrowserDownloadURL)
if err != nil {
return false, err.Error()
}
defer resp.Body.Close()
DownloadFile(saveFile, assetWin.BrowserDownloadURL, func(percent float64, speed float64) {
application.Get().EmitEvent("download:server", map[string]interface{}{
"percent": fmt.Sprintf("%.2f", percent),
"speed": fmt.Sprintf("%.2f", speed),
})
})
return true, ""
}
func (g *GitService) UnzipServer() {
unzipParallel(filepath.Join(constant.ServerStorageUrl, constant.ServerZipFile), constant.ServerStorageUrl)
os.Remove(filepath.Join(constant.ServerStorageUrl, constant.ServerZipFile))
}
func (g *GitService) GetLatestProxyVersion(oldVersion string) (bool, string, string) {
resp, err := http.Get(constant.ProxyGitUrl)
if err != nil {
return false, "", err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, "", err.Error()
}
if len(releases) == 0 {
return false, "", "no releases found"
}
return true, releases[0].TagName, ""
}
func (g *GitService) DownloadProxyProgress(version string) (bool, string) {
resp, err := http.Get(constant.ProxyGitUrl)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []*models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, err.Error()
}
if len(releases) == 0 {
return false, "no releases found"
}
var releaseData *models.ReleaseType
for _, release := range releases {
if release.TagName == version {
releaseData = release
break
}
}
if releaseData == nil || releaseData.TagName == "" {
return false, "no release found"
}
var assetWin models.AssetType
for _, asset := range releaseData.Assets {
if asset.Name == constant.ProxyZipFile {
assetWin = asset
break
}
}
if assetWin.Name == "" {
return false, "no assets found"
}
if err := os.Mkdir(constant.ProxyStorageUrl, 0755); err != nil {
if !os.IsExist(err) {
return false, err.Error()
}
}
saveFile := filepath.Join(constant.ProxyStorageUrl, assetWin.Name)
resp, err = http.Get(assetWin.BrowserDownloadURL)
if err != nil {
return false, err.Error()
}
defer resp.Body.Close()
DownloadFile(saveFile, assetWin.BrowserDownloadURL, func(percent float64, speed float64) {
application.Get().EmitEvent("download:proxy", map[string]interface{}{
"percent": fmt.Sprintf("%.2f", percent),
"speed": fmt.Sprintf("%.2f", speed),
})
})
return true, ""
}
func (g *GitService) UnzipProxy() {
unzipParallel(filepath.Join(constant.ProxyStorageUrl, constant.ProxyZipFile), constant.ProxyStorageUrl)
os.Remove(filepath.Join(constant.ProxyStorageUrl, constant.ProxyZipFile))
}

212
internal/hdiffz-service.go Normal file
View File

@@ -0,0 +1,212 @@
package internal
import (
"bufio"
"encoding/json"
"firefly-launcher/pkg/constant"
"firefly-launcher/pkg/models"
"firefly-launcher/pkg/sevenzip"
"firefly-launcher/pkg/verifier"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"github.com/wailsapp/wails/v3/pkg/application"
)
type HdiffzService struct{}
func (h *HdiffzService) VersionValidate(gamePath, patchPath string) (bool, string) {
oldVersionData, err := models.ParseBinaryVersion(filepath.Join(gamePath, "StarRail_Data\\StreamingAssets\\BinaryVersion.bytes"))
if err != nil {
return false, err.Error()
}
if _, err := os.Stat(patchPath); err != nil {
return false, err.Error()
}
if _, err := os.Stat(constant.TempUrl); os.IsNotExist(err) {
if err := os.MkdirAll(constant.TempUrl, os.ModePerm); err != nil {
return false, err.Error()
}
}
if err := sevenzip.ExtractAFileFromZip(patchPath, "StarRail_Data\\StreamingAssets\\BinaryVersion.bytes", constant.TempUrl); err != nil {
return false, err.Error()
}
binPath := filepath.Join(constant.TempUrl, "BinaryVersion.bytes")
newVersionData, err := models.ParseBinaryVersion(binPath)
if err != nil {
return false, err.Error()
}
defer os.Remove(binPath)
v := newVersionData.Subtract(*oldVersionData)
if v != 0 && v != 1 {
return false, fmt.Sprintf("the diff version %s not valid with game version %s", newVersionData, oldVersionData)
}
return true, "validated"
}
func (h *HdiffzService) DataExtract(gamePath, patchPath string) (bool, string) {
if _, err := os.Stat(gamePath); err != nil {
return false, err.Error()
}
if _, err := os.Stat(patchPath); err != nil {
return false, err.Error()
}
if _, err := os.Stat(constant.TempUrl); os.IsNotExist(err) {
if err := os.MkdirAll(constant.TempUrl, os.ModePerm); err != nil {
return false, err.Error()
}
}
if err := sevenzip.ExtractAllFilesFromZip(patchPath, constant.TempUrl); err != nil {
os.RemoveAll(constant.TempUrl)
return false, err.Error()
}
validator, err := verifier.NewVerifier(gamePath, constant.TempUrl)
if err != nil {
os.RemoveAll(constant.TempUrl)
return false, err.Error()
}
if err := validator.VerifyAll(); err != nil {
os.RemoveAll(constant.TempUrl)
return false, err.Error()
}
return true, "validated"
}
func (h *HdiffzService) CutData(gamePath string) (bool, string) {
if _, err := os.Stat(constant.TempUrl); os.IsNotExist(err) {
return false, err.Error()
}
err := filepath.Walk(constant.TempUrl, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(constant.TempUrl, path)
if err != nil {
return err
}
destPath := filepath.Join(gamePath, relPath)
application.Get().EmitEvent("hdiffz:message", map[string]string{"message": destPath})
if info.IsDir() {
return os.MkdirAll(destPath, os.ModePerm)
}
if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
return err
}
if err := os.Rename(path, destPath); err != nil {
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(destPath)
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
_ = os.Remove(path)
}
return nil
})
if err != nil {
return false, err.Error()
}
_ = os.RemoveAll(constant.TempUrl)
return true, "cut completed"
}
func (h *HdiffzService) PatchData(gamePath string) (bool, string) {
data, err := os.ReadFile(filepath.Join(gamePath, "hdiffmap.json"))
if err != nil {
return false, err.Error()
}
var jsonData struct {
DiffMap []*models.DiffMapType `json:"diff_map"`
}
if err := json.Unmarshal(data, &jsonData); err != nil {
return false, err.Error()
}
for i, entry := range jsonData.DiffMap {
application.Get().EmitEvent(
"hdiffz:progress", map[string]int{
"progress": i,
"maxProgress": len(jsonData.DiffMap),
})
sourceFile := filepath.Join(gamePath, entry.SourceFileName)
patchFile := filepath.Join(gamePath, entry.PatchFileName)
targetFile := filepath.Join(gamePath, entry.TargetFileName)
if _, err := os.Stat(sourceFile); os.IsNotExist(err) {
continue
}
if _, err := os.Stat(patchFile); os.IsNotExist(err) {
continue
}
cmd := exec.Command(constant.ToolHPatchzExe.String(), sourceFile, patchFile, targetFile)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
_, err := cmd.CombinedOutput()
if err != nil {
continue
}
}
return true, "patching completed"
}
func (h *HdiffzService) DeleteFiles(gamePath string) (bool, string) {
var deleteFiles []string
file, err := os.Open(filepath.Join(gamePath, "deletefiles.txt"))
if err != nil {
return false, ""
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
deleteFiles = append(deleteFiles, line)
}
}
if err := scanner.Err(); err != nil {
return false, ""
}
for i, file := range deleteFiles {
os.Remove(filepath.Join(gamePath, file))
application.Get().EmitEvent("hdiffz:progress", map[string]int{"progress": i, "maxProgress": len(deleteFiles)})
}
return true, ""
}

View File

@@ -0,0 +1,146 @@
package internal
import (
"bytes"
"fmt"
"os"
"path/filepath"
)
type LanguageService struct{}
func isValidLang(lang string) bool {
valid := []string{"en", "jp", "cn", "kr"}
for _, v := range valid {
if lang == v {
return true
}
}
return false
}
func (l *LanguageService) GetLanguage(path string) (string, string, error) {
files, err := os.ReadDir(path)
if err != nil {
return "", "", err
}
for _, file := range files {
filePath := filepath.Join(path, file.Name())
content, err := os.ReadFile(filePath)
if err != nil {
continue
}
patternToFind := []byte("SpriteOutput/UI/Fonts/RPG_CN.ttf")
idx := bytes.Index(content, patternToFind)
if idx == -1 {
continue
}
pattern := []byte("Korean")
idx = bytes.Index(content, pattern)
if idx == -1 {
continue
}
// Move to os text language
idx += 10
idx += 4
osText := string(content[idx : idx+2])
idx += 3 * 4
// Move to cn voice language
idx += 1
idx += 5
cnVoice := string(content[idx : idx+2])
idx += 3 * 2 // skip 2 entries
// Move to os voice language
idx += 1
idx += 5
osVoice := string(content[idx : idx+2])
idx += 3 * 5 // skip 5 entries
// Move to cn text language
idx += 1
idx += 4
cnText := string(content[idx : idx+2])
textLang := osText
voiceLang := osVoice
if !isValidLang(textLang) {
textLang = cnText
}
if !isValidLang(voiceLang) {
voiceLang = cnVoice
}
return textLang, voiceLang, nil
}
return "", "", fmt.Errorf("couldn't find file to read language from")
}
func replaceBytes(content []byte, idx int, choice string, param int) int {
for i := 0; i < param; i++ {
copy(content[idx:idx+2], []byte(choice))
idx += 3
}
return idx
}
func (l *LanguageService) SetLanguage(path string, text, voice string) (bool, error) {
files, err := os.ReadDir(path)
if err != nil {
return false, err
}
for _, file := range files {
filePath := filepath.Join(path, file.Name())
content, err := os.ReadFile(filePath)
if err != nil {
continue
}
patternToFind := []byte("SpriteOutput/UI/Fonts/RPG_CN.ttf")
idx := bytes.Index(content, patternToFind)
if idx == -1 {
continue
}
pattern := []byte("Korean")
idx = bytes.Index(content, pattern)
if idx == -1 {
continue
}
idx += 10
idx += 4
idx = replaceBytes(content, idx, text, 4)
idx += 1
idx += 5
idx = replaceBytes(content, idx, voice, 2)
idx += 1
idx += 5
idx = replaceBytes(content, idx, voice, 5)
idx += 1
idx += 4
_ = replaceBytes(content, idx, text, 2)
err = os.WriteFile(filePath, content, 0644)
if err != nil {
return false, err
}
return true, nil
}
return false, fmt.Errorf("couldn't find file to patch. Make sure this file is placed in the correct folder")
}

192
internal/util.go Normal file
View File

@@ -0,0 +1,192 @@
package internal
import (
"archive/zip"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"runtime"
"sync"
"time"
)
func humanFormat(bytes int64) string {
n := float64(bytes)
for _, unit := range []string{"", "Ki", "Mi", "Gi"} {
if math.Abs(n) < 1024.0 {
return fmt.Sprintf("%3.1f%sB", n, unit)
}
n /= 1024.0
}
return fmt.Sprintf("%.1fTiB", n)
}
type WriteCounter struct {
Total uint64
StartTime time.Time
OnEmit func(percent float64, speedMBps float64)
TotalSize int64
lastLoggedPercent int
}
func NewWriteCounter(total int64, onEmit func(percent float64, speedMBps float64)) *WriteCounter {
return &WriteCounter{
StartTime: time.Now(),
TotalSize: total,
lastLoggedPercent: -1,
OnEmit: onEmit,
}
}
func (wc *WriteCounter) Write(p []byte) (int, error) {
n := len(p)
wc.Total += uint64(n)
wc.PrintProgress()
return n, nil
}
func (wc *WriteCounter) PrintProgress() {
elapsed := time.Since(wc.StartTime).Seconds()
if elapsed < 0.001 {
elapsed = 0.001
}
speed := float64(wc.Total) / 1024 / 1024 / elapsed // MB/s
percent := float64(wc.Total) / float64(wc.TotalSize) * 100
if wc.OnEmit != nil {
wc.OnEmit(percent, speed)
}
}
func DownloadFile(filepath string, url string, onEmit func(percent float64, speed float64)) error {
tmpPath := filepath + ".tmp"
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to get file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
out, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("failed to create tmp file: %w", err)
}
counter := NewWriteCounter(resp.ContentLength, onEmit)
_, err = io.Copy(out, io.TeeReader(resp.Body, counter))
if closeErr := out.Close(); closeErr != nil {
return fmt.Errorf("failed to close tmp file: %w", closeErr)
}
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
// Delete destination file if it exists
if _, err := os.Stat(filepath); err == nil {
if err := os.Remove(filepath); err != nil {
return fmt.Errorf("failed to remove existing file: %w", err)
}
}
for i := 0; i < 3; i++ {
err = os.Rename(tmpPath, filepath)
if err == nil {
break
}
time.Sleep(300 * time.Millisecond)
}
if err != nil {
return fmt.Errorf("failed to rename after retries: %w", err)
}
return nil
}
func unzipParallel(src string, dest string) error {
numCPU := runtime.NumCPU()
reserved := 1
if numCPU > 4 {
reserved = 2
}
maxWorkers := numCPU - reserved
if maxWorkers < 1 {
maxWorkers = 1
}
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
err = os.MkdirAll(dest, 0755)
if err != nil {
return err
}
type job struct {
f *zip.File
}
jobs := make(chan job)
var wg sync.WaitGroup
// Worker pool
for i := 0; i < maxWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
err := extractFile(j.f, dest)
if err != nil {
fmt.Printf("Error extracting %s: %v\n", j.f.Name, err)
}
}
}()
}
// Feed jobs
for _, f := range r.File {
jobs <- job{f}
}
close(jobs)
wg.Wait()
return nil
}
func extractFile(f *zip.File, dest string) error {
fp := filepath.Join(dest, f.Name)
if f.FileInfo().IsDir() {
return os.MkdirAll(fp, f.Mode())
}
err := os.MkdirAll(filepath.Dir(fp), 0755)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.OpenFile(fp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, rc)
return err
}