feat: customize launcher version check and CI/CD release workflow for G-Launcher

This commit is contained in:
CAtlas0784 committed 2026-10-30 21:07:59 +07:00
1 parent a5205d1bed
commit 88a1f1c40a
12 files changed
+416 -42

No files matched your search

+133
View File
@@ -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
+1 -2
View File
@@ -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!
+1 -2
View File
@@ -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}}
+5 -5
View File
@@ -5,11 +5,11 @@
"info": {
"0000": {
"ProductVersion": "1.0.0",
"CompanyName": "Firefly Shelter",
"FileDescription": "Firefly Launcher",
"LegalCopyright": "@ 2025, Firefly Shelter",
"ProductName": "Firefly Launcher",
"Comments": "Firefly Launcher"
"CompanyName": "GaCy",
"FileDescription": "G-Launcher",
"LegalCopyright": "@ 2026, GaCy",
"ProductName": "G-Launcher",
"Comments": "G-Launcher"
}
}
}
+4 -4
View File
@@ -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"
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.fireflyshelter.fireflylauncher" version="1.0.0" processorArchitecture="*"/>
<assemblyIdentity type="win32" name="com.gacy.glauncher" version="1.0.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
+147 -25
View File
@@ -1,48 +1,78 @@
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{}
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 {
return false, "", err.Error()
}
if err == nil {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
body, readErr := io.ReadAll(resp.Body)
if readErr == nil {
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"
}
if json.Unmarshal(body, &releases) == nil && len(releases) > 0 {
return true, releases[0].TagName, ""
}
func (g *GitService) UpdateLauncherProgress(version string) (bool, string) {
asset, ok := g.getReleaseAsset(version, constant.LauncherGitUrl, constant.LauncherFile)
if !ok {
return false, "no release found"
}
}
}
return false, "", "no launcher updates found"
}
func (g *GitService) UpdateLauncherProgress(version string) (bool, string) {
if !constant.LauncherUpdatesEnabled {
return false, "launcher updates are disabled in development builds"
}
// 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)
}
}
// 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()
@@ -55,3 +85,95 @@ func (g *GitService) UpdateLauncherProgress(version string) (bool, string) {
}
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, ""
}
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
}
+6 -3
View File
@@ -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
@@ -0,0 +1,5 @@
//go:build !production
package constant
const LauncherUpdatesEnabled = false
@@ -0,0 +1,5 @@
//go:build production
package constant
const LauncherUpdatesEnabled = true
+74
View File
@@ -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 <version> | manifest <version> <exe> <output> | stamp-info <version> <info.json>",
);
}
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;
});
}
+34
View File
@@ -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 });
}
});