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/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/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 }); + } +});