diff --git a/.github/workflows/build-launcher.yml b/.github/workflows/build-launcher.yml new file mode 100644 index 0000000..8eb3a6f --- /dev/null +++ b/.github/workflows/build-launcher.yml @@ -0,0 +1,133 @@ +name: Build G-Launcher Release + +on: + workflow_dispatch: + inputs: + tag_name: + description: 'Release Tag Name (e.g. latest, release, v1.0.0)' + required: false + default: 'release' + custom_version: + description: 'Override Version (leave blank for auto-increment)' + required: false + default: '' + push: + tags: + - 'v*' + +permissions: + contents: write + +concurrency: + group: build-g-launcher-release + cancel-in-progress: false + +jobs: + build: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install Wails CLI + shell: pwsh + run: | + go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74 + "$env:USERPROFILE\go\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Calculate launcher version + shell: pwsh + run: | + $customVersion = "${{ github.event.inputs.custom_version }}" + $releaseTag = if ("${{ github.event.inputs.tag_name }}") { "${{ github.event.inputs.tag_name }}" } else { "release" } + + if ($customVersion -and $customVersion.Trim() -ne "") { + $version = $customVersion.Trim() + Write-Host "Using user-specified version: $version" + } else { + $manifestPath = Join-Path $env:RUNNER_TEMP "latest.json" + # Attempt to fetch latest.json from current repository release + $repo = "${{ github.repository }}" + $manifestUrl = "https://github.com/$repo/releases/download/$releaseTag/latest.json" + $cacheBust = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + $requestUrl = "$($manifestUrl)?cachebust=$cacheBust" + + Write-Host "Checking current manifest from: $requestUrl" + $status = & curl.exe --location --silent --show-error --output $manifestPath --write-out "%{http_code}" $requestUrl + + if ($status -eq "200") { + $currentVersion = (Get-Content -Raw $manifestPath | ConvertFrom-Json).version + Write-Host "Found previous version: $currentVersion" + $version = node scripts/launcher-release.mjs next $currentVersion + } else { + Write-Host "No previous manifest found (HTTP $status). Starting at 1.0.0" + $version = "1.0.0" + } + } + + [System.IO.File]::AppendAllText($env:GITHUB_ENV, "LAUNCHER_VERSION=$version" + [Environment]::NewLine) + [System.IO.File]::AppendAllText($env:GITHUB_ENV, "RELEASE_TAG=$releaseTag" + [Environment]::NewLine) + + node scripts/launcher-release.mjs stamp-info $version build/windows/info.json + Write-Host "Configured G-Launcher version $version" + + - name: Install Frontend Dependencies + working-directory: frontend + run: npm ci + + - name: Build Frontend + working-directory: frontend + run: npm run build + + - name: Build Windows Executable + shell: pwsh + run: | + wails3 task build BUILD_VERSION=$env:LAUNCHER_VERSION PRODUCTION=true + env: + PRODUCTION: "true" + + - name: Prepare Release Assets + shell: pwsh + run: | + $builtLauncher = "bin/G-Launcher.exe" + if (!(Test-Path $builtLauncher)) { + $fallback = Get-ChildItem -Path "bin" -Filter "*.exe" | Select-Object -First 1 + if ($fallback) { + $builtLauncher = $fallback.FullName + } else { + throw "Build output was not found in bin directory." + } + } + + $releaseLauncher = Join-Path $env:RUNNER_TEMP "G-Launcher.exe" + Copy-Item -LiteralPath $builtLauncher -Destination $releaseLauncher -Force + + $manifestPath = Join-Path $env:RUNNER_TEMP "latest.json" + node scripts/launcher-release.mjs manifest $env:LAUNCHER_VERSION $releaseLauncher $manifestPath + + Write-Host "Release assets prepared:" + Get-Item $releaseLauncher, $manifestPath | Select-Object Name, Length + + - name: Publish Release Assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + files: | + ${{ runner.temp }}/G-Launcher.exe + ${{ runner.temp }}/latest.json + overwrite_files: true + fail_on_unmatched_files: true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2b4b290 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# 📜 G-Launcher Development Rules & Engineering Guidelines +> กฎและแนวทางการพัฒนา G-Launcher จากบทเรียนโปรเจกต์เดิมและคำแนะนำของ Horoyoi-san + +--- + +### 1. 🏗️ แก้ที่ระบบหลังบ้านก่อน UI เสมอ (System-First Architecture) +* **คำแนะนำจาก Horoyoi:** *"ระบบมันรองรับ build zig แนะนำตอนแก้ ให้แก้ที่ระบบก่อน แล้วค่อยไปแก้ UI หน้าต่างต่างๆ ของ launcher ไม่งั้นเดี๋ยวพัง"* +* **แนวปฏิบัติ:** + 1. ทุกครั้งที่เพิ่มฟีเจอร์หรือแก้ไข logic ให้เริ่มที่ **Go Backend** (`internal/`, `pkg/`, `main.go`) ให้เสร็จสิ้น + 2. รัน `go vet ./...` เพื่อให้แน่ใจว่าไม่มี type error หรือ compilation error + 3. Generate หรืออัปเดต TypeScript Bindings (`@bindings/...`) + 4. จากนั้นจึงค่อยไปแก้ที่ **Frontend UI** (`frontend/src/`) เพื่อเชื่อมต่อกับ API / Event ของ Backend + 5. **เหตุผล:** Wails v3 มี Bindings และ Event IPC ที่ผูกกันแน่น หากแก้ UI ก่อนโดยที่ Backend ยังไม่พร้อม หน้าต่าง Launcher จะ crash หรือเกิด runtime exception ทันที + +--- + +### 2. 🚫 ห้าม Hardcode เด็ดขาด (Strict No-Hardcoding Rule) +* **ห้ามฝังค่าต่อไปนี้ในโค้ด logic โดยตรง:** + * URL, Domain, IP Address, หรือ Port (เช่น API Endpoint, Git Release URLs, Proxy URLs) + * File Path สัมบูรณ์ (ห้ามใส่ `C:\...` หรือ Drive Letter ใดๆ) + * ชื่อไฟล์หรือโฟลเดอร์เฉพาะทางที่อาจเปลี่ยนแปลงได้ +* **แนวปฏิบัติ:** + * รวมศูนย์ค่า Configuration ไว้ที่ [`pkg/constant/constant.go`](file:///c:/Users/Phitchayut/Desktop/G%20Launcher/pkg/constant/constant.go) หรือไฟล์ Configuration กลาง + * สำหรับ Path ให้ใช้ `filepath.Join(...)` เสมอ และเก็บข้อมูลผู้ใช้ไว้ที่ `os.UserConfigDir()` (`%APPDATA%`) + * เปิดช่องให้ผู้ใช้สามารถปรับแต่ง URL เซิร์ฟเวอร์, พร็อกซี หรือพารามิเตอร์ของเกมได้ผ่าน UI Settings หรือ Config File + +--- + +### 3. 🏷️ การเปลี่ยนชื่อและ References (Refactoring & Naming Rule) +* **คำแนะนำจาก Horoyoi:** *"ส่วนชื่อที่เป็น sunbringer หรือชื่อผมค่อยไปเปลี่ยนทีหลังนะ"* +* **แนวปฏิบัติ:** + * ห้ามใช้วิธี Find-and-Replace ชื่อโปรเจกต์เดิม (เช่น `sunbringer`, `firefly`) แบบสุ่มสี่สุ่มห้าทั่วทั้งโปรเจกต์ เพราะอาจกระทบ internal protocol, protobuf identifier, build manifest หรือ package bindings + * แยก User-Facing Branding (เช่น ชื่อหน้าต่าง, Title, Tooltip, Config metadata) ออกจาก System/Package naming ให้ชัดเจน + * เปลี่ยนชื่อส่วนที่จำเป็นเมื่อระบบ core นิ่งแล้วเท่านั้น + +--- + +### 4. 🛡️ ความปลอดภัยของข้อมูลและการ Rollback (Data Safety & Rollback) +* **บทเรียนจาก GaC-hkrpg-go / Ananta:** + * ไฟล์ตัวเกม Hoyoverse / Unity มีขนาดใหญ่มากและเสียหายได้ง่าย + * **Safe Backup:** ก่อนจะ Patch, แก้ไข หรือเขียนทับไฟล์ใดๆ ของเกม (เช่น `.dll`, `.exe`, asset binaries) จะต้องสำรองไฟล์เดิมไว้เป็นนามสกุล `.backup` เสมอ + * **Atomic Rollback:** หากการ Copy, Verify หรือ Download เกิดความล้มเหลว ระบบจะต้อง Rollback นำไฟล์สำรองกลับคืนมาทันที ห้ามทิ้งไฟล์เกมให้อยู่ในสภาพเสียหาย + * **Atomic Write สำหรับ Config:** การบันทึกไฟล์คอนฟิก (เช่น `game-selection.json`) ให้เขียนเป็น `.tmp` ก่อน แล้วใช้ Windows API `MoveFileEx` เพื่อป้องกันไฟล์เสียหายจากไฟดับหรือ crash + +--- + +### 5. 🧰 Zero-Dependency & Portability First +* ไม่สมมติว่าเครื่องผู้ใช้ปลายทางมี Git, Zig หรือ 7-Zip อยู่ใน PATH +* ต้องมี Toolchain Helper และ Portable Runtimes ในตัว: + * **MinGit:** ใช้ระบบ Auto-download MinGit portable หากตรวจไม่พบ Git ในระบบ + * **Tools Extraction:** ดึง binary tools (`7za.exe`, `hpatchz.exe`, `zift.exe`) ออกจาก embed assets อัตโนมัติเมื่อเริ่มโปรแกรม + +--- + +### 6. 🌿 Git Workflow & Code Hygiene +* **Active Branch:** ทำงานและ Commit บน branch **`Dev`** เท่านั้น +* **Pre-commit Verification:** + * ฝั่ง Go: `go vet ./...` ต้องผ่าน 100% + * ฝั่ง Frontend: `npm run build` ต้องผ่าน 100% +* **Clean Repository:** รักษาความสะอาดของ `.gitignore` ห้าม commit ไฟล์ `_references/`, `dist/`, `node_modules/`, `bin/`, `temp/`, หรือ `server/` เข้าสู่ Git diff --git a/Makefile b/Makefile index a43b99e..a1f4775 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,5 @@ release: update: @echo Updating build assets... - wails3 update build-assets -name "firefly-launcher" -binaryname "firefly-launcher" -config build/config.yml + wails3 update build-assets -name "g-launcher" -binaryname "g-launcher" -config build/config.yml @echo Done! - diff --git a/README.md b/README.md new file mode 100644 index 0000000..cdb9166 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# 🚀 G-Launcher + +
+ +![G-Launcher Banner](frontend/public/bg-1.jpeg) + +**A lightweight, modern, and high-performance game launcher for Anime & Hoyoverse games.** +Built with **Go**, **Wails v3**, **React 19**, and **Tailwind CSS v4**. + +[![Go Version](https://img.shields.io/badge/Go-1.24%2B-00ADD8?style=flat&logo=go)](https://go.dev/) +[![Wails Version](https://img.shields.io/badge/Wails-v3--alpha-DF0000?style=flat&logo=wails)](https://v3.wails.io/) +[![React Version](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react)](https://react.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.9%2B-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/) +[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-v4-38B2AC?style=flat&logo=tailwind-css)](https://tailwindcss.com/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +[English](#-english) | [ภาษาไทย](#-ภาษาไทย) + +
+ +--- + +## 🇹🇭 ภาษาไทย + +### ✨ ฟีเจอร์เด่น (Key Features) + +* **🎮 Game Process & Environment Management:** + * ตรวจสอบและเปิดเกมพร้อมพารามิเตอร์ที่ถูกต้องผ่านระบบจัดการ Console ของ Windows (`CREATE_NEW_CONSOLE | CREATE_BREAKAWAY_FROM_JOB`) + * ตรวจจับสถานะการทำงานของตัวเกมและเซิร์ฟเวอร์แบบ Real-time พร้อมแจ้งเตือนเมื่อเกมปิดตัวลง +* **🌐 ระบบหลายภาษา (Multi-Language UI & Game Patcher):** + * หน้า UI รองรับ **ภาษาไทย (TH)**, **English (EN)**, **日本語 (JA)**, **한국어 (KO)**, **Tiếng Việt (VI)**, และ **中文 (ZH)** + * เครื่องมือสลับภาษาภายในเกมผ่าน Binary `ExcelData` Patcher (EN, JP, CN, KR) และรองรับ Thai Mod Pack สำหรับตัวเกม Beta +* **📦 ระบบอัปเดตไฟล์เกมแบบลดขนาด (Incremental Diff Patching):** + * รองรับไฟล์อัปเดตแบบ **HDiffPatch** โดยตรงจาก Chunk Protobuf Manifest ของเกม (`proto/firefly.proto`) + * รองรับการลงแพตช์ไฟล์นามสกุล `.ziff` ด้วย **Zift Tool** (`zift.exe`) + * ตรวจสอบความถูกต้องของไฟล์ผ่าน Checksum (MD5 / SHA-256) +* **🔄 ระบบอัปเดต Launcher อัตโนมัติ (Smart Self-Update):** + * อัปเดตผ่าน Manifest `latest.json` พร้อมตรวจสอบความสมบูรณ์ด้วย SHA-256 + * มีระบบ Fallback ไปยัง Gitea/GitHub Releases API + * ป้องกันการทับไฟล์ในโหมด Development อัตโนมัติ +* **🛡️ ความปลอดภัยของข้อมูล (Safe Data & Atomic Operations):** + * จดจำโฟลเดอร์เกมใน AppData (`%APPDATA%/g-launcher/game-selection.json`) ด้วย Windows API `MoveFileEx` แบบ Atomic ป้องกันไฟล์เสียหาย + * สำรองไฟล์ตัวจริงเป็น `.backup` ก่อนลงแพตช์ และมีระบบ Rollback คืนสภาพไฟล์อัตโนมัติหากการลงแพตช์ล้มเหลว +* **🧰 พร้อมใช้งานแบบไม่ต้องติดตั้งเครื่องมือเสริม (Zero-Dependency):** + * **Portable MinGit:** ดาวน์โหลด Git แบบพกพามาติดตั้งในตัวอัตโนมัติหากตรวจไม่พบ Git ในเครื่องผู้ใช้ + * บรรจุ Binary Tools สำคัญ (`7za.exe`, `hpatchz.exe`, `zift.exe`) ไว้ในตัวโปรแกรมและแตกไฟล์ใช้งานอัตโนมัติ +* **🎨 ปรับแต่งหน้าตาได้ตามใจ (Customization & Themes):** + * หน้าต่างไร้ขอบ (Frameless Glassmorphism) พร้อมถาดระบบ (System Tray Menu) + * ระบบเปลี่ยนภาพพื้นหลัง (Background Selector) รองรับทั้งคลังภาพในตัว, อัปโหลดจากเครื่อง หรือใส่ Image URL + +--- + +### 🏗️ สถาปัตยกรรมระบบ (Architecture) + +```mermaid +flowchart TD + subgraph Frontend["Frontend (React 19 + TypeScript + Vite)"] + UI["UI Pages & Modals"] + Router["TanStack Router"] + Store["Zustand Stores"] + I18n["i18next Locales"] + Bindings["TypeScript Bindings (@bindings/*)"] + UI --> Router + Router --> Store + Store --> Bindings + UI --> I18n + end + + subgraph IPC["Wails v3 IPC Gateway"] + IPCBridge["Bidirectional Events & RPC Bindings"] + end + + subgraph Backend["Backend (Go 1.24+)"] + AppSvc["AppService (Window Controls)"] + FSSvc["FSService (Process & Storage)"] + GitSvc["GitService (Update & MinGit)"] + DiffSvc["DiffService (HDiff & Zift)"] + LangSvc["LanguageService (Excel & Mod)"] + end + + subgraph System["Windows OS & Game Files"] + GameExe["Game Process (StarRail.exe)"] + GameFiles["Game Assets & Backup Files"] + Config["User Config (%APPDATA%/g-launcher)"] + Tools["Tools (MinGit, 7za, Zift)"] + end + + Bindings <--> IPCBridge + IPCBridge <--> AppSvc + IPCBridge <--> FSSvc + IPCBridge <--> GitSvc + IPCBridge <--> DiffSvc + IPCBridge <--> LangSvc + + FSSvc --> GameExe + FSSvc --> Config + GitSvc --> Tools + DiffSvc --> GameFiles + LangSvc --> GameFiles +``` + +--- + +### 💻 ข้อกำหนดของระบบและเครื่องมือพัฒนา (Prerequisites) + +* [Go 1.24+](https://go.dev/dl/) +* [Node.js 22+](https://nodejs.org/) +* [Wails v3 CLI](https://v3.wails.io/): + ```bash + go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74 + ``` + +--- + +### 🛠️ วิธีการรันและบิลด์ (Development & Build) + +#### 1. ติดตั้ง Dependencies +```bash +# ติดตั้งแพ็กเกจ Frontend +cd frontend +npm install +cd .. + +# ติดตั้งโมดูล Go +go mod tidy +``` + +#### 2. รันในโหมดพัฒนา (Development Mode) +```bash +# รัน Wails dev (มี Hot Reload ทั้ง Frontend และ Backend) +wails3 dev + +# หรือรันสคริปต์ +.\run.bat +``` + +#### 3. บิลด์สำหรับใช้งานจริง (Production Build) +```bash +# บิลด์แบบปกติผ่าน Wails Task +wails3 task build PRODUCTION=true + +# หรือใช้สคริปต์ Build พร้อมบีบอัดขนาดไฟล์ด้วย UPX +.\build.bat +``` + +#### 4. สร้างตัวติดตั้ง (Installer Packaging) +```bash +wails3 task package +``` + +--- + +## 🇬🇧 English + +### ✨ Features + +* **Game Process Orchestration:** Launch games with decoupled console execution flags (`CREATE_NEW_CONSOLE | CREATE_BREAKAWAY_FROM_JOB`) with active process lifecycle hooks. +* **Full Multi-Language Support:** Fully localized in English, Thai, Japanese, Korean, Vietnamese, and Chinese with live switching in the header. Includes game asset translation utilities. +* **Smart Incremental Patching:** Parse Hoyoverse manifest protobuf chunks, apply `.hdiff` delta patches with `hpatchz`, and apply `.ziff` archives via `zift`. +* **Reliable CI/CD Self-Updating:** Integrated release workflows via `.github/workflows/build-launcher.yml` and `scripts/launcher-release.mjs` verifying SHA-256 hashes against `latest.json`. +* **Zero-Dependency Tooling:** Auto-configures MinGit portable and extracts required binaries (`7za.exe`, `hpatchz.exe`, `zift.exe`) automatically. +* **Data Safety First:** Atomic config updates with Windows `MoveFileEx` and automatic rollback on patch failure. + +--- + +### 📁 Project Structure + +``` +G-Launcher/ +├── .github/workflows/ # GitHub Actions release & build workflows +├── assets/ # Embedded binary tools (7za, hpatchz, zift, appicon) +├── build/ # Wails build configurations & OS templates +│ ├── config.yml # App metadata (CompanyName, ProductName, AppID) +│ └── windows/ # Windows manifests, icons, info.json +├── frontend/ # React 19 Frontend application +│ ├── bindings/ # Auto-generated Wails TypeScript bindings +│ ├── public/ # Static assets, backgrounds, locales (en, th, ja, ko, vi, zh) +│ └── src/ # React components, pages, stores, hooks +├── internal/ # Go Backend Services +│ ├── app-service/ # Window lifecycle & version management +│ ├── diff-service/ # HDiffz, Ziff, and LDiff chunk patching +│ ├── fs-service/ # File dialogs, process runner, persistent storage +│ ├── git-service/ # Self-update, server/proxy downloader, portable MinGit +│ └── language-service/ # ExcelData binary patcher & Thai mod engine +├── pkg/ # Shared Go packages & constants +│ ├── constant/ # Centralized URLs & configuration +│ ├── firefly/ # Protobuf parser & chunk loader +│ ├── hpatchz/ # HPatchz wrapper +│ └── sevenzip/ # 7-Zip archive utilities +├── proto/ # Protobuf definitions +├── scripts/ # Release & version calculation scripts (launcher-release.mjs) +├── AGENTS.md # Engineering guidelines & development rules +├── build.bat # Windows build & UPX compression script +├── main.go # Application entry point & service registration +└── Taskfile.yml # Wails build tasks definition +``` + +--- + +### 📜 Engineering Guidelines + +1. **System-First Rule:** Always implement and verify changes in the **Go Backend** (`internal/`, `pkg/`) first. Run `go vet ./...` and generate bindings before touching Frontend UI. +2. **Strict No-Hardcoding:** Do not hardcode URLs, IP addresses, ports, or absolute drive paths. Centralize constants in [`pkg/constant/constant.go`](pkg/constant/constant.go). +3. **Branch Workflow:** All active development takes place on branch **`Dev`**. + +--- + +### 🤝 Credits & Acknowledgments + +* **[Firefly Shelter](https://git.kain.io.vn/Firefly-Shelter/Firefly_Launcher)** — The original upstream foundation, proxy architecture, and UI design. +* **[Horoyoi-san](https://github.com/horoyoi-san/game-Launcher)** — Architectural enhancements (portable MinGit, safe rollback, Zig integration patterns, and update workflows). +* **[Wails](https://v3.wails.io/)** — Next-generation desktop application framework for Go. +* Developed with ❤️ by **GaCy**. diff --git a/Taskfile.yml b/Taskfile.yml index 68db0be..a05d590 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -7,7 +7,7 @@ includes: linux: ./build/linux/Taskfile.yml vars: - APP_NAME: "firefly-launcher" + APP_NAME: "G-Launcher" BIN_DIR: "bin" VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' @@ -31,4 +31,3 @@ tasks: summary: Runs the application in development mode cmds: - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} - diff --git a/build/windows/info.json b/build/windows/info.json index 4ef303f..8012c74 100644 --- a/build/windows/info.json +++ b/build/windows/info.json @@ -1,15 +1,15 @@ { - "fixed": { - "file_version": "1.0.0" - }, - "info": { - "0000": { - "ProductVersion": "1.0.0", - "CompanyName": "Firefly Shelter", - "FileDescription": "Firefly Launcher", - "LegalCopyright": "@ 2025, Firefly Shelter", - "ProductName": "Firefly Launcher", - "Comments": "Firefly Launcher" - } - } -} \ No newline at end of file + "fixed": { + "file_version": "1.0.0" + }, + "info": { + "0000": { + "ProductVersion": "1.0.0", + "CompanyName": "GaCy", + "FileDescription": "G-Launcher", + "LegalCopyright": "@ 2026, GaCy", + "ProductName": "G-Launcher", + "Comments": "G-Launcher" + } + } +} diff --git a/build/windows/nsis/wails_tools.nsh b/build/windows/nsis/wails_tools.nsh index 95ee9fa..5fa65da 100644 --- a/build/windows/nsis/wails_tools.nsh +++ b/build/windows/nsis/wails_tools.nsh @@ -5,19 +5,19 @@ !include "FileFunc.nsh" !ifndef INFO_PROJECTNAME - !define INFO_PROJECTNAME "firefly-launcher" + !define INFO_PROJECTNAME "g-launcher" !endif !ifndef INFO_COMPANYNAME - !define INFO_COMPANYNAME "Firefly Shelter" + !define INFO_COMPANYNAME "GaCy" !endif !ifndef INFO_PRODUCTNAME - !define INFO_PRODUCTNAME "Firefly Launcher" + !define INFO_PRODUCTNAME "G-Launcher" !endif !ifndef INFO_PRODUCTVERSION !define INFO_PRODUCTVERSION "1.0.0" !endif !ifndef INFO_COPYRIGHT - !define INFO_COPYRIGHT "@ 2025, Firefly Shelter" + !define INFO_COPYRIGHT "@ 2025, GaCy" !endif !ifndef PRODUCT_EXECUTABLE !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest index f0d70ed..d2f300b 100644 --- a/build/windows/wails.exe.manifest +++ b/build/windows/wails.exe.manifest @@ -1,6 +1,6 @@ - + diff --git a/internal/git-service/launcher.go b/internal/git-service/launcher.go index 930504c..3c2235b 100644 --- a/internal/git-service/launcher.go +++ b/internal/git-service/launcher.go @@ -1,57 +1,179 @@ package gitService import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" "encoding/json" - "g-launcher/pkg/constant" - "g-launcher/pkg/models" + "fmt" "io" "net/http" - "github.com/minio/selfupdate" + "net/url" + "os" + "regexp" + "strings" + "time" + "g-launcher/pkg/constant" + "g-launcher/pkg/models" + + "github.com/minio/selfupdate" ) type GitService struct{} - -func (g *GitService) GetLatestLauncherVersion() (bool, string, string) { - resp, err := http.Get(constant.LauncherGitUrl) - 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, "" +type launcherManifest struct { + Version string `json:"version"` + SHA256 string `json:"sha256"` } +var launcherVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) +func (g *GitService) GetLatestLauncherVersion() (bool, string, string) { + // First, attempt to check via latest.json manifest + if constant.LauncherManifestURL != "" { + manifest, err := fetchLauncherManifest(constant.LauncherManifestURL) + if err == nil && manifest.Version != "" { + return true, manifest.Version, "" + } + } + + // Fallback to Gitea/Forgejo/GitHub Releases API + if constant.LauncherGitUrl != "" { + resp, err := http.Get(constant.LauncherGitUrl) + if err == nil { + defer resp.Body.Close() + body, readErr := io.ReadAll(resp.Body) + if readErr == nil { + var releases []models.ReleaseType + if json.Unmarshal(body, &releases) == nil && len(releases) > 0 { + return true, releases[0].TagName, "" + } + } + } + } + + return false, "", "no launcher updates found" +} func (g *GitService) UpdateLauncherProgress(version string) (bool, string) { - asset, ok := g.getReleaseAsset(version, constant.LauncherGitUrl, constant.LauncherFile) - if !ok { - return false, "no release found" + if !constant.LauncherUpdatesEnabled { + return false, "launcher updates are disabled in development builds" } - resp, err := http.Get(asset.BrowserDownloadURL) - if err != nil { - return false, err.Error() + // Mode 1: Manifest-based update with SHA-256 integrity verification + if constant.LauncherManifestURL != "" && constant.LauncherDownloadURL != "" { + manifest, err := fetchLauncherManifest(constant.LauncherManifestURL) + if err == nil && manifest.Version == version && manifest.SHA256 != "" { + return g.applyManifestUpdate(manifest) + } } - defer resp.Body.Close() - err = selfupdate.Apply(resp.Body, selfupdate.Options{}) - if err != nil { - return false, err.Error() + // Mode 2: Release API fallback + if constant.LauncherGitUrl != "" { + asset, ok := g.getReleaseAsset(version, constant.LauncherGitUrl, constant.LauncherFile) + if ok && asset.BrowserDownloadURL != "" { + resp, err := http.Get(asset.BrowserDownloadURL) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + + err = selfupdate.Apply(resp.Body, selfupdate.Options{}) + if err != nil { + return false, err.Error() + } + return true, "" + } } + + return false, "could not locate valid launcher update package" +} + +func (g *GitService) applyManifestUpdate(manifest launcherManifest) (bool, string) { + client := &http.Client{Timeout: 10 * time.Minute} + response, err := client.Get(constant.LauncherDownloadURL) + if err != nil { + return false, fmt.Errorf("download launcher update: %w", err).Error() + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return false, fmt.Sprintf("download launcher update: server returned %s", response.Status) + } + + temporaryFile, err := os.CreateTemp("", "g-launcher-update-*.exe") + if err != nil { + return false, fmt.Errorf("create temporary launcher update: %w", err).Error() + } + temporaryPath := temporaryFile.Name() + defer os.Remove(temporaryPath) + + hash := sha256.New() + if _, err := io.Copy(io.MultiWriter(temporaryFile, hash), response.Body); err != nil { + temporaryFile.Close() + return false, fmt.Errorf("save launcher update: %w", err).Error() + } + if err := temporaryFile.Close(); err != nil { + return false, fmt.Errorf("close launcher update: %w", err).Error() + } + + expectedHash, err := hex.DecodeString(manifest.SHA256) + if err != nil || subtle.ConstantTimeCompare(hash.Sum(nil), expectedHash) != 1 { + return false, "launcher update checksum mismatch" + } + + updateFile, err := os.Open(temporaryPath) + if err != nil { + return false, fmt.Errorf("open launcher update: %w", err).Error() + } + defer updateFile.Close() + if err := selfupdate.Apply(updateFile, selfupdate.Options{}); err != nil { + return false, fmt.Errorf("apply launcher update: %w", err).Error() + } + return true, "" -} \ No newline at end of file +} + +func fetchLauncherManifest(manifestURL string) (launcherManifest, error) { + parsedURL, err := url.Parse(manifestURL) + if err != nil || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") { + return launcherManifest{}, fmt.Errorf("invalid launcher manifest URL") + } + query := parsedURL.Query() + query.Set("_", fmt.Sprintf("%d", time.Now().UnixNano())) + parsedURL.RawQuery = query.Encode() + + request, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return launcherManifest{}, fmt.Errorf("create launcher manifest request: %w", err) + } + request.Header.Set("Cache-Control", "no-cache") + request.Header.Set("User-Agent", "G-Launcher") + + client := &http.Client{Timeout: 20 * time.Second} + response, err := client.Do(request) + if err != nil { + return launcherManifest{}, fmt.Errorf("check launcher updates: %w", err) + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return launcherManifest{}, fmt.Errorf("check launcher updates: server returned %s", response.Status) + } + + var manifest launcherManifest + if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&manifest); err != nil { + return launcherManifest{}, fmt.Errorf("read launcher update manifest: %w", err) + } + if !launcherVersionPattern.MatchString(manifest.Version) { + return launcherManifest{}, fmt.Errorf("launcher update manifest has invalid version %q", manifest.Version) + } + manifest.SHA256 = strings.TrimSpace(manifest.SHA256) + if len(manifest.SHA256) != sha256.Size*2 { + return launcherManifest{}, fmt.Errorf("launcher update manifest has invalid SHA-256") + } + if _, err := hex.DecodeString(manifest.SHA256); err != nil { + return launcherManifest{}, fmt.Errorf("launcher update manifest has invalid SHA-256: %w", err) + } + manifest.SHA256 = strings.ToLower(manifest.SHA256) + return manifest, nil +} diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index 1654d4c..e0496fc 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -2,15 +2,18 @@ package constant const ProxyGitUrl = "https://git.kain.io.vn/api/v1/repos/Firefly-Shelter/FireflyGo_Proxy/releases" const ServerGitUrl = "https://git.kain.io.vn/api/v1/repos/Firefly-Shelter/FireflyGo_Local_Archive/releases" -const LauncherGitUrl = "https://git.kain.io.vn/api/v1/repos/Firefly-Shelter/Firefly_Launcher/releases" +const LauncherGitUrl = "https://gaytea.cod-algieba.ts.net/api/v1/repos/GaCy/G-Launcher/releases" +const LauncherManifestURL = "https://gaytea.cod-algieba.ts.net/GaCy/G-Launcher/releases/download/latest/latest.json" +const LauncherDownloadURL = "https://gaytea.cod-algieba.ts.net/GaCy/G-Launcher/releases/download/latest/G-Launcher.exe" + const ServerStorageUrl = "./server" const ProxyStorageUrl = "./proxy" const ServerZipFile = "prebuild_win_x86.zip" const ProxyFile = "firefly-go-proxy.exe" -const LauncherFile = "g-launcher.exe" +const LauncherFile = "G-Launcher.exe" const TempUrl = "./temp" -const CurrentLauncherVersion = "1.0.0" +var CurrentLauncherVersion = "1.0.0" type ToolFile string diff --git a/pkg/constant/launcher_updates_development.go b/pkg/constant/launcher_updates_development.go new file mode 100644 index 0000000..e3a4a86 --- /dev/null +++ b/pkg/constant/launcher_updates_development.go @@ -0,0 +1,5 @@ +//go:build !production + +package constant + +const LauncherUpdatesEnabled = false diff --git a/pkg/constant/launcher_updates_production.go b/pkg/constant/launcher_updates_production.go new file mode 100644 index 0000000..fa4bc73 --- /dev/null +++ b/pkg/constant/launcher_updates_production.go @@ -0,0 +1,5 @@ +//go:build production + +package constant + +const LauncherUpdatesEnabled = true diff --git a/scripts/launcher-release.mjs b/scripts/launcher-release.mjs new file mode 100644 index 0000000..fda1577 --- /dev/null +++ b/scripts/launcher-release.mjs @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +export function nextLauncherVersion(currentVersion) { + const match = currentVersion.match(versionPattern); + if (!match) { + throw new Error(`Invalid launcher version: ${currentVersion}`); + } + + let [major, minor, patch] = match.slice(1).map(Number); + patch += 1; + if (patch === 10) { + patch = 0; + minor += 1; + if (minor === 10) { + minor = 0; + major += 1; + } + } + + return `${major}.${minor}.${patch}`; +} + +export async function createLauncherManifest(version, executablePath) { + if (!versionPattern.test(version)) { + throw new Error(`Invalid launcher version: ${version}`); + } + const executable = await readFile(executablePath); + return { + version, + sha256: createHash("sha256").update(executable).digest("hex"), + }; +} + +async function main(args) { + const [command, ...values] = args; + if (command === "next" && values.length === 1) { + console.log(nextLauncherVersion(values[0])); + return; + } + + if (command === "manifest" && values.length === 3) { + const [version, executablePath, manifestPath] = values; + const manifest = await createLauncherManifest(version, executablePath); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + return; + } + + if (command === "stamp-info" && values.length === 2) { + const [version, infoPath] = values; + if (!versionPattern.test(version)) { + throw new Error(`Invalid launcher version: ${version}`); + } + const info = JSON.parse(await readFile(infoPath, "utf8")); + info.fixed.file_version = version; + info.info["0000"].ProductVersion = version; + await writeFile(infoPath, `${JSON.stringify(info, null, 2)}\n`); + return; + } + + throw new Error( + "Usage: launcher-release.mjs next | manifest | stamp-info ", + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/launcher-release.test.mjs b/scripts/launcher-release.test.mjs new file mode 100644 index 0000000..385326a --- /dev/null +++ b/scripts/launcher-release.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { createLauncherManifest, nextLauncherVersion } from "./launcher-release.mjs"; + +test("increments patch digits and rolls over minor and major digits", () => { + assert.equal(nextLauncherVersion("2.1.0"), "2.1.1"); + assert.equal(nextLauncherVersion("2.1.9"), "2.2.0"); + assert.equal(nextLauncherVersion("2.9.9"), "3.0.0"); +}); + +test("rejects versions outside the three-number sequence", () => { + assert.throws(() => nextLauncherVersion("2.1"), /Invalid launcher version/); + assert.throws(() => nextLauncherVersion("02.1.0"), /Invalid launcher version/); +}); + +test("creates a manifest with the executable SHA-256", async () => { + const directory = await mkdtemp(join(tmpdir(), "sunbringer-release-test-")); + const executablePath = join(directory, "launcher.exe"); + + try { + await writeFile(executablePath, "test executable"); + const manifest = await createLauncherManifest("2.1.1", executablePath); + assert.equal(manifest.version, "2.1.1"); + assert.equal( + manifest.sha256, + "ff8715f0270731bbdb0bb3586a77d032f5e883b8909dcafbf3e8909cb8c71201", + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +});