From aad2b3eb1e3b52d96211abadc765a228dc16a8ef Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 24 Jun 2026 18:11:22 +0200 Subject: [PATCH] feat(update): add rolling dev update channel for per-commit builds Adds an opt-in Dev channel so panels running CI per-commit builds can self-update to the latest commit, mirroring the stable online-update flow. CI publishes/overwrites a single fixed-tag pre-release (dev-latest), force-moved to the newest main commit and marked --latest=false so releases/latest stays the stable tag. Builds stamp the short commit via -ldflags; the panel compares the running commit to the dev release commit to detect an update, and update.sh honors XUI_UPDATE_TAG to install from that tag. Linux/systemd only. --- .github/workflows/release.yml | 73 ++++++++- frontend/public/openapi.json | 43 ++++++ frontend/src/pages/api-docs/endpoints.ts | 6 + frontend/src/pages/index/IndexPage.tsx | 29 +++- frontend/src/pages/index/PanelUpdateModal.tsx | 66 +++++++- internal/config/config.go | 26 ++++ internal/web/controller/server.go | 14 ++ internal/web/service/panel/panel.go | 142 ++++++++++++++++-- internal/web/service/panel/panel_test.go | 70 ++++++++- internal/web/service/server.go | 5 +- internal/web/service/setting.go | 52 ++++--- internal/web/translation/ar-EG.json | 5 + internal/web/translation/en-US.json | 5 + internal/web/translation/es-ES.json | 5 + internal/web/translation/fa-IR.json | 5 + internal/web/translation/id-ID.json | 5 + internal/web/translation/ja-JP.json | 5 + internal/web/translation/pt-BR.json | 5 + internal/web/translation/ru-RU.json | 5 + internal/web/translation/tr-TR.json | 5 + internal/web/translation/uk-UA.json | 5 + internal/web/translation/vi-VN.json | 5 + internal/web/translation/zh-CN.json | 5 + internal/web/translation/zh-TW.json | 5 + update.sh | 13 +- 25 files changed, 556 insertions(+), 48 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08b945719..20c3f3204 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,7 +97,13 @@ jobs: export CC=$(realpath "$(find "$TOOLCHAIN_DIR/bin" -name '*-gcc.br_real' -type f -executable | head -n1)") [ -z "$CC" ] && { echo "No gcc.br_real found in $TOOLCHAIN_DIR/bin" >&2; exit 1; } cd - - go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go + # Stamp the commit into per-commit (dev channel) builds only; tagged + # stable releases stay unstamped so config.IsDevBuild() returns false. + LDFLAGS="-w -s -linkmode external -extldflags '-static'" + if [[ "$GITHUB_REF" != refs/tags/* ]]; then + LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA::8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + fi + go build -ldflags "$LDFLAGS" -o xui-release -v main.go file xui-release ldd xui-release || echo "Static binary confirmed" @@ -245,7 +251,12 @@ jobs: go version gcc --version - go build -ldflags "-w -s" -o xui-release.exe -v main.go + # Stamp the commit into per-commit (dev channel) builds only. + LDFLAGS="-w -s" + if [[ "$GITHUB_REF" != refs/tags/* ]]; then + LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA:0:8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + fi + go build -ldflags "$LDFLAGS" -o xui-release.exe -v main.go - name: Copy and download resources shell: pwsh @@ -302,3 +313,61 @@ jobs: asset_name: x-ui-windows-amd64.zip overwrite: true prerelease: true + + # ================================= + # Rolling dev channel (per-commit) + # ================================= + # Publishes/overwrites the build artifacts to a single fixed-tag pre-release + # `dev-latest`, force-moved to the new commit on every push to main. The panel's + # "Dev" update channel installs from this tag. `--latest=false` is load-bearing: + # it keeps releases/latest pointing at the real stable tag, so the stable + # channel is unaffected. + publish-dev: + name: Publish rolling dev release + needs: [build, build-windows] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + # Serialize racing pushes; never cancel an in-flight upload, or the dev + # release could be left with a partial asset set. + concurrency: + group: dev-release + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Download all build artifacts + uses: actions/download-artifact@v7 + with: + path: dev-artifacts + merge-multiple: true + + - name: Publish dev-latest pre-release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT: ${{ github.sha }} + run: | + set -e + short="${COMMIT::8}" + notes="Rolling development build — installs via the panel's Dev update channel. + + commit=${COMMIT} + built=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + Automated per-commit build from main. Not a stable release." + + # Force-move the dev-latest tag to this commit so the release tracks it. + git tag -f dev-latest "${COMMIT}" + git push -f origin refs/tags/dev-latest + + if gh release view dev-latest >/dev/null 2>&1; then + gh release edit dev-latest --prerelease --latest=false \ + --title "Dev build ${short}" --notes "${notes}" + else + gh release create dev-latest --prerelease --latest=false \ + --target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}" + fi + + gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index c071a9fe1..1b0b04124 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -4240,6 +4240,49 @@ } } }, + "/panel/api/server/setUpdateChannel": { + "post": { + "tags": [ + "Server" + ], + "summary": "Toggle the panel update channel between stable and the rolling per-commit dev release. Only effective on dev builds.", + "operationId": "post_panel_api_server_setUpdateChannel", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "dev": true + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/server/updateGeofile": { "post": { "tags": [ diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 960d5dd20..dc3b58b60 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -400,6 +400,12 @@ export const sections: readonly Section[] = [ path: '/panel/api/server/updatePanel', summary: 'Self-update the panel to the latest version. The server restarts on success.', }, + { + method: 'POST', + path: '/panel/api/server/setUpdateChannel', + summary: 'Toggle the panel update channel between stable and the rolling per-commit dev release. Only effective on dev builds.', + body: '{\n "dev": true\n}', + }, { method: 'POST', path: '/panel/api/server/updateGeofile', diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx index 6d2a73c90..496bba24a 100644 --- a/frontend/src/pages/index/IndexPage.tsx +++ b/frontend/src/pages/index/IndexPage.tsx @@ -65,6 +65,8 @@ export default function IndexPage() { useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); const [accessLogEnable, setAccessLogEnable] = useState(false); + const [isDevBuild, setIsDevBuild] = useState(false); + const [devChannelEnable, setDevChannelEnable] = useState(false); const [panelUpdateInfo, setPanelUpdateInfo] = useState({ currentVersion: '', latestVersion: '', @@ -87,8 +89,14 @@ export default function IndexPage() { const [loadingTip, setLoadingTip] = useState(t('loading')); useEffect(() => { - HttpUtil.post<{ accessLogEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => { - if (msg?.success && msg.obj) setAccessLogEnable(!!msg.obj.accessLogEnable); + HttpUtil.post<{ accessLogEnable?: boolean; isDevBuild?: boolean; devChannelEnable?: boolean }>( + '/panel/api/setting/defaultSettings', + ).then((msg) => { + if (msg?.success && msg.obj) { + setAccessLogEnable(!!msg.obj.accessLogEnable); + setIsDevBuild(!!msg.obj.isDevBuild); + setDevChannelEnable(!!msg.obj.devChannelEnable); + } }); HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => { if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj); @@ -119,13 +127,21 @@ export default function IndexPage() { }, [refresh]); function openPanelVersion() { - if (panelUpdateInfo.updateAvailable) { + if (panelUpdateInfo.updateAvailable || isDevBuild) { setPanelUpdateOpen(true); } else { window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer'); } } + async function handleChannelChange(dev: boolean) { + const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev }); + if (!res?.success) return; + setDevChannelEnable(dev); + const msg = await HttpUtil.get('/panel/api/server/getPanelUpdateInfo'); + if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj); + } + function openTelegram() { window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer'); } @@ -224,7 +240,9 @@ export default function IndexPage() { {isMobile && displayVersion && ( {panelUpdateInfo.updateAvailable - ? `v${panelUpdateInfo.latestVersion}` + ? panelUpdateInfo.channel === 'dev' + ? panelUpdateInfo.latestVersion + : `v${panelUpdateInfo.latestVersion}` : `v${displayVersion}`} )} @@ -446,6 +464,9 @@ export default function IndexPage() { setPanelUpdateOpen(false)} onBusy={setBusy} /> diff --git a/frontend/src/pages/index/PanelUpdateModal.tsx b/frontend/src/pages/index/PanelUpdateModal.tsx index 0afc37e6e..1268ee2d8 100644 --- a/frontend/src/pages/index/PanelUpdateModal.tsx +++ b/frontend/src/pages/index/PanelUpdateModal.tsx @@ -1,5 +1,6 @@ +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, Button, Modal, Tag } from 'antd'; +import { Alert, Button, Modal, Switch, Tag } from 'antd'; import { CloudDownloadOutlined } from '@ant-design/icons'; import axios from 'axios'; @@ -7,8 +8,11 @@ import { HttpUtil, PromiseUtil } from '@/utils'; import './PanelUpdateModal.css'; export interface PanelUpdateInfo { + channel?: string; currentVersion: string; latestVersion: string; + currentCommit?: string; + latestCommit?: string; updateAvailable: boolean; } @@ -20,13 +24,27 @@ interface BusyEvent { interface PanelUpdateModalProps { open: boolean; info: PanelUpdateInfo; + isDevBuild?: boolean; + devChannelEnable?: boolean; + onChannelChange?: (dev: boolean) => void | Promise; onClose: () => void; onBusy: (e: BusyEvent) => void; } -export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelUpdateModalProps) { +export default function PanelUpdateModal({ + open, + info, + isDevBuild, + devChannelEnable, + onChannelChange, + onClose, + onBusy, +}: PanelUpdateModalProps) { const { t } = useTranslation(); const [modal, contextHolder] = Modal.useModal(); + const [channelBusy, setChannelBusy] = useState(false); + + const isDev = info.channel === 'dev'; async function pollUntilBack(): Promise { await PromiseUtil.sleep(5000); @@ -43,6 +61,16 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU return false; } + async function handleChannel(checked: boolean) { + if (!onChannelChange) return; + setChannelBusy(true); + try { + await onChannelChange(checked); + } finally { + setChannelBusy(false); + } + } + function updatePanel() { modal.confirm({ title: t('pages.index.panelUpdateDialog'), @@ -84,15 +112,41 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU /> )} + {isDevBuild && ( +
+
+ {t('pages.index.devChannel')} + +
+
+ )} + + {devChannelEnable && ( + + )} +
- {t('pages.index.currentPanelVersion')} - v{info.currentVersion || '?'} + {isDev ? t('pages.index.currentCommit') : t('pages.index.currentPanelVersion')} + {isDev ? ( + {info.currentCommit || '?'} + ) : ( + v{info.currentVersion || '?'} + )}
{info.updateAvailable ? (
- {t('pages.index.latestPanelVersion')} - {info.latestVersion || '-'} + {isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')} + {(isDev ? info.latestCommit : info.latestVersion) || '-'}
) : (
diff --git a/internal/config/config.go b/internal/config/config.go index 48f39e522..bff101847 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,15 @@ var version string //go:embed name var name string +// buildCommit and buildDate are injected at build time via `-ldflags -X` for +// CI per-commit (dev channel) builds; see .github/workflows/release.yml. They +// stay empty for a plain `go build` and for stable tagged releases, which is how +// IsDevBuild tells a rolling dev build apart from a stable/local one. +var ( + buildCommit string + buildDate string +) + // LogLevel represents the logging level for the application. type LogLevel string @@ -42,6 +51,23 @@ func GetName() string { return strings.TrimSpace(name) } +// GetBuildCommit returns the short git commit this binary was built from, or an +// empty string for a plain/local build or a stable tagged release. +func GetBuildCommit() string { + return strings.TrimSpace(buildCommit) +} + +// GetBuildDate returns the UTC build timestamp injected at build time, or empty. +func GetBuildDate() string { + return strings.TrimSpace(buildDate) +} + +// IsDevBuild reports whether this binary is a CI per-commit (dev channel) build, +// detected by the injected commit. Stable releases and local builds return false. +func IsDevBuild() bool { + return GetBuildCommit() != "" +} + // GetLogLevel returns the current logging level based on environment variables or defaults to Info. func GetLogLevel() LogLevel { if IsDebug() { diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index 58d8bf371..aeef01700 100644 --- a/internal/web/controller/server.go +++ b/internal/web/controller/server.go @@ -69,6 +69,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.POST("/restartXrayService", a.restartXrayService) g.POST("/installXray/:version", a.installXray) g.POST("/updatePanel", a.updatePanel) + g.POST("/setUpdateChannel", a.setUpdateChannel) g.POST("/updateGeofile", a.updateGeofile) g.POST("/updateGeofile/:fileName", a.updateGeofile) g.POST("/logs/:count", a.getLogs) @@ -211,6 +212,19 @@ func (a *ServerController) updatePanel(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err) } +// setUpdateChannel toggles whether self-update tracks the rolling dev release. +func (a *ServerController) setUpdateChannel(c *gin.Context) { + var req struct { + Dev bool `json:"dev"` + } + if err := c.ShouldBindJSON(&req); err != nil { + jsonMsg(c, "invalid data", err) + return + } + err := a.settingService.SetDevChannelEnable(req.Dev) + jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err) +} + // updateGeofile updates the specified geo file for Xray. func (a *ServerController) updateGeofile(c *gin.Context) { fileName := c.Param("fileName") diff --git a/internal/web/service/panel/panel.go b/internal/web/service/panel/panel.go index e39371648..42fbdc571 100644 --- a/internal/web/service/panel/panel.go +++ b/internal/web/service/panel/panel.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strconv" "strings" @@ -25,17 +26,27 @@ import ( type PanelService struct{} // PanelUpdateInfo contains the current and latest available panel versions. +// On the dev channel the version fields carry a "dev+" label and the commit +// fields hold the short SHAs that drive the update-available decision. type PanelUpdateInfo struct { + Channel string `json:"channel"` CurrentVersion string `json:"currentVersion"` LatestVersion string `json:"latestVersion"` + CurrentCommit string `json:"currentCommit,omitempty"` + LatestCommit string `json:"latestCommit,omitempty"` UpdateAvailable bool `json:"updateAvailable"` } const ( panelUpdaterURL = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh" maxPanelUpdaterBytes = 2 << 20 + // devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the + // newest main commit; the dev update channel installs from it. + devReleaseTag = "dev-latest" ) +var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`) + func (s *PanelService) RestartPanel(delay time.Duration) error { go func() { time.Sleep(delay) @@ -58,20 +69,59 @@ func (s *PanelService) RestartPanel(delay time.Duration) error { return nil } -// GetUpdateInfo checks GitHub for the latest 3x-ui release. +// GetUpdateInfo checks GitHub for the latest 3x-ui release. When the dev channel +// is enabled on a dev build it compares commits against the rolling dev release; +// otherwise it compares versions against the latest stable tag. func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) { + if devChannelActive() { + return getDevUpdateInfo() + } latest, err := fetchLatestPanelVersion() if err != nil { return nil, err } current := config.GetVersion() return &PanelUpdateInfo{ + Channel: "stable", CurrentVersion: current, LatestVersion: latest, UpdateAvailable: isNewerVersion(latest, current), }, nil } +// devChannelActive reports whether self-update should track the rolling dev +// release. It requires both the opt-in setting and a dev build, so a stable +// binary with the toggle left on never cross-grades to the dev channel. +func devChannelActive() bool { + if !config.IsDevBuild() { + return false + } + enabled, err := (&service.SettingService{}).GetDevChannelEnable() + return err == nil && enabled +} + +// getDevUpdateInfo compares the running commit against the commit recorded in the +// rolling dev release. +func getDevUpdateInfo() (*PanelUpdateInfo, error) { + release, err := fetchPanelRelease(devReleaseTag) + if err != nil { + return nil, err + } + latestCommit := extractReleaseCommit(release) + if latestCommit == "" { + return nil, fmt.Errorf("dev release commit is unknown") + } + currentCommit := config.GetBuildCommit() + return &PanelUpdateInfo{ + Channel: "dev", + CurrentVersion: config.GetVersion(), + CurrentCommit: shortCommit(currentCommit), + LatestCommit: shortCommit(latestCommit), + LatestVersion: "dev+" + shortCommit(latestCommit), + UpdateAvailable: !commitsEqual(currentCommit, latestCommit), + }, nil +} + // StartUpdate starts the official updater outside of the current web request. func (s *PanelService) StartUpdate() error { if runtime.GOOS != "linux" { @@ -89,6 +139,10 @@ func (s *PanelService) StartUpdate() error { } mainFolder, serviceFolder := resolveUpdateFolders() + updateTag := "" + if devChannelActive() { + updateTag = devReleaseTag + } updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath)) if systemdRun, err := exec.LookPath("systemd-run"); err == nil { @@ -97,6 +151,7 @@ func (s *PanelService) StartUpdate() error { "--unit", unitName, "--setenv", "XUI_MAIN_FOLDER="+mainFolder, "--setenv", "XUI_SERVICE="+serviceFolder, + "--setenv", "XUI_UPDATE_TAG="+updateTag, bash, "-lc", updateScript, ) out, err := cmd.CombinedOutput() @@ -118,6 +173,7 @@ func (s *PanelService) StartUpdate() error { cmd.Env = append(os.Environ(), "XUI_MAIN_FOLDER="+mainFolder, "XUI_SERVICE="+serviceFolder, + "XUI_UPDATE_TAG="+updateTag, ) setDetachedProcess(cmd) if err := cmd.Start(); err != nil { @@ -170,26 +226,88 @@ func downloadPanelUpdater() (string, error) { } func fetchLatestPanelVersion() (string, error) { - client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second) - resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest") + release, err := fetchPanelRelease("") if err != nil { return "", err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status) - } - - var release service.Release - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return "", err - } if release.TagName == "" { return "", fmt.Errorf("latest panel release tag is empty") } return release.TagName, nil } +// fetchPanelRelease fetches a release from GitHub. An empty tag resolves the +// latest stable release; a non-empty tag (e.g. dev-latest) resolves that tag. +func fetchPanelRelease(tag string) (*service.Release, error) { + url := "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" + if tag != "" { + url = "https://api.github.com/repos/MHSanaei/3x-ui/releases/tags/" + tag + } + client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second) + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status) + } + + var release service.Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + return &release, nil +} + +// extractReleaseCommit reads the build commit recorded in the dev release: first +// the `commit=` marker the CI writes into the body, falling back to the +// tag's target commit. +func extractReleaseCommit(release *service.Release) string { + if m := releaseCommitRegex.FindStringSubmatch(release.Body); m != nil { + return strings.ToLower(m[1]) + } + if isCommitSHA(release.TargetCommitish) { + return strings.ToLower(release.TargetCommitish) + } + return "" +} + +func isCommitSHA(s string) bool { + s = strings.TrimSpace(s) + if len(s) < 7 || len(s) > 40 { + return false + } + for _, r := range s { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { + return false + } + } + return true +} + +func shortCommit(sha string) string { + sha = strings.TrimSpace(sha) + if len(sha) > 8 { + return sha[:8] + } + return sha +} + +// commitsEqual compares a short (injected) commit against a full release commit +// by prefix, so an 8-char build stamp matches the 40-char release SHA. +func commitsEqual(a, b string) bool { + a = strings.ToLower(strings.TrimSpace(a)) + b = strings.ToLower(strings.TrimSpace(b)) + if a == "" || b == "" { + return false + } + if len(a) > len(b) { + a, b = b, a + } + return strings.HasPrefix(b, a) +} + func resolveUpdateFolders() (string, string) { mainFolder := os.Getenv("XUI_MAIN_FOLDER") if mainFolder == "" { diff --git a/internal/web/service/panel/panel_test.go b/internal/web/service/panel/panel_test.go index 6660580ce..94dc8d122 100644 --- a/internal/web/service/panel/panel_test.go +++ b/internal/web/service/panel/panel_test.go @@ -1,6 +1,10 @@ package panel -import "testing" +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) func TestIsNewerVersion(t *testing.T) { cases := []struct { @@ -39,3 +43,67 @@ func TestShellQuote(t *testing.T) { t.Fatalf("unexpected quote result with single quote: %s", got) } } + +func TestExtractReleaseCommit(t *testing.T) { + full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b" + cases := []struct { + name string + release service.Release + want string + }{ + { + name: "from body marker", + release: service.Release{Body: "Rolling build\n\ncommit=" + full + "\nbuilt=2026-06-24T00:00:00Z"}, + want: full, + }, + { + name: "body marker is case-insensitive and wins over target", + release: service.Release{Body: "COMMIT=" + full, TargetCommitish: "deadbeef"}, + want: full, + }, + { + name: "fallback to target commit sha", + release: service.Release{Body: "no marker here", TargetCommitish: full}, + want: full, + }, + { + name: "branch target is not a commit", + release: service.Release{Body: "no marker", TargetCommitish: "main"}, + want: "", + }, + } + for _, tc := range cases { + if got := extractReleaseCommit(&tc.release); got != tc.want { + t.Fatalf("%s: extractReleaseCommit = %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestCommitsEqual(t *testing.T) { + full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b" + cases := []struct { + a, b string + want bool + }{ + {"1a2b3c4d", full, true}, // injected 8-char prefix matches full release sha + {full, "1a2b3c4d", true}, // order independent + {"1A2B3C4D", full, true}, // case insensitive + {"deadbeef", full, false}, // different commit + {"", full, false}, // empty current never matches + {"1a2b3c4d", "", false}, // empty latest never matches + } + for _, tc := range cases { + if got := commitsEqual(tc.a, tc.b); got != tc.want { + t.Fatalf("commitsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} + +func TestShortCommit(t *testing.T) { + if got := shortCommit("1a2b3c4d5e6f7a8b"); got != "1a2b3c4d" { + t.Fatalf("shortCommit truncation = %q, want %q", got, "1a2b3c4d") + } + if got := shortCommit("abc"); got != "abc" { + t.Fatalf("shortCommit short input = %q, want %q", got, "abc") + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index bab17a323..acb269799 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -117,7 +117,10 @@ type Status struct { // Release represents information about a software release from GitHub. type Release struct { - TagName string `json:"tag_name"` // The tag name of the release + TagName string `json:"tag_name"` // The tag name of the release + Body string `json:"body"` // The release notes; the dev channel reads its commit from here + TargetCommitish string `json:"target_commitish"` // The branch/commit the tag points at + Prerelease bool `json:"prerelease"` // Whether this is a pre-release } // ServerService provides business logic for server monitoring and management. diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 2bf5b13c6..13d7682cd 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -14,6 +14,7 @@ import ( "time" "github.com/google/uuid" + "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -109,6 +110,7 @@ var defaultValueMap = map[string]string{ "restartXrayOnClientDisable": "true", "xrayOutboundTestUrl": "https://www.google.com/generate_204", "panelOutbound": "", + "devChannelEnable": "false", // LDAP defaults "ldapEnable": "false", @@ -855,6 +857,16 @@ func (s *SettingService) SetRestartXrayOnClientDisable(value bool) error { return s.setBool("restartXrayOnClientDisable", value) } +// GetDevChannelEnable reports whether the panel self-update tracks the rolling +// per-commit dev release instead of the latest stable tag. +func (s *SettingService) GetDevChannelEnable() (bool, error) { + return s.getBool("devChannelEnable") +} + +func (s *SettingService) SetDevChannelEnable(value bool) error { + return s.setBool("devChannelEnable", value) +} + // GetIpLimitEnable reports whether the IP-limit feature is available. Always // true since the panel enforces limits via the core's online-stats API; on an // older core the job falls back to access-log parsing and warns there when the @@ -1209,25 +1221,27 @@ func (s *SettingService) BuildSubURIBase(host string) string { func (s *SettingService) GetDefaultSettings(host string) (any, error) { type settingFunc func() (any, error) settings := map[string]settingFunc{ - "expireDiff": func() (any, error) { return s.GetExpireDiff() }, - "trafficDiff": func() (any, error) { return s.GetTrafficDiff() }, - "pageSize": func() (any, error) { return s.GetPageSize() }, - "defaultCert": func() (any, error) { return s.GetCertFile() }, - "defaultKey": func() (any, error) { return s.GetKeyFile() }, - "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, - "subThemeDir": func() (any, error) { return s.GetSubThemeDir() }, - "subEnable": func() (any, error) { return s.GetSubEnable() }, - "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() }, - "subClashEnable": func() (any, error) { return s.GetSubClashEnable() }, - "subTitle": func() (any, error) { return s.GetSubTitle() }, - "subURI": func() (any, error) { return s.GetSubURI() }, - "subJsonURI": func() (any, error) { return s.GetSubJsonURI() }, - "subClashURI": func() (any, error) { return s.GetSubClashURI() }, - "datepicker": func() (any, error) { return s.GetDatepicker() }, - "ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() }, - "accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() }, - "webDomain": func() (any, error) { return s.GetWebDomain() }, - "subDomain": func() (any, error) { return s.GetSubDomain() }, + "expireDiff": func() (any, error) { return s.GetExpireDiff() }, + "trafficDiff": func() (any, error) { return s.GetTrafficDiff() }, + "pageSize": func() (any, error) { return s.GetPageSize() }, + "defaultCert": func() (any, error) { return s.GetCertFile() }, + "defaultKey": func() (any, error) { return s.GetKeyFile() }, + "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, + "subThemeDir": func() (any, error) { return s.GetSubThemeDir() }, + "subEnable": func() (any, error) { return s.GetSubEnable() }, + "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() }, + "subClashEnable": func() (any, error) { return s.GetSubClashEnable() }, + "subTitle": func() (any, error) { return s.GetSubTitle() }, + "subURI": func() (any, error) { return s.GetSubURI() }, + "subJsonURI": func() (any, error) { return s.GetSubJsonURI() }, + "subClashURI": func() (any, error) { return s.GetSubClashURI() }, + "datepicker": func() (any, error) { return s.GetDatepicker() }, + "ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() }, + "accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() }, + "webDomain": func() (any, error) { return s.GetWebDomain() }, + "subDomain": func() (any, error) { return s.GetSubDomain() }, + "devChannelEnable": func() (any, error) { return s.GetDevChannelEnable() }, + "isDevBuild": func() (any, error) { return config.IsDevBuild(), nil }, } result := make(map[string]any) diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 6e93eed3a..312c69e9c 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -153,6 +153,11 @@ "currentPanelVersion": "إصدار البانل الحالي", "latestPanelVersion": "أحدث إصدار للبانل", "panelUpToDate": "البانل محدث لآخر إصدار", + "devChannel": "قناة التطوير", + "devChannelWarning": "تتابع نسخ التطوير كل كومِت على main وليست إصدارات مستقرة — لا يوجد رجوع تلقائي لإصدار أقدم.", + "currentCommit": "الكومِت الحالي", + "latestCommit": "أحدث كومِت", + "updateChannelChanged": "تم تغيير قناة التحديث", "upToDate": "محدث", "xrayStatusUnknown": "مش معروف", "xrayStatusRunning": "شغالة", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 9ca908768..9b5651d02 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -153,6 +153,11 @@ "currentPanelVersion": "Current panel version", "latestPanelVersion": "Latest panel version", "panelUpToDate": "Panel is up to date", + "devChannel": "Dev channel", + "devChannelWarning": "Dev builds track every commit on main and aren't stable releases — there is no automatic downgrade.", + "currentCommit": "Current commit", + "latestCommit": "Latest commit", + "updateChannelChanged": "Update channel changed", "upToDate": "Up to date", "xrayStatusUnknown": "Unknown", "xrayStatusRunning": "Running", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 3aba8b3ab..0a5a13dd7 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -153,6 +153,11 @@ "currentPanelVersion": "Versión actual del panel", "latestPanelVersion": "Última versión del panel", "panelUpToDate": "El panel está actualizado", + "devChannel": "Canal de desarrollo", + "devChannelWarning": "Las compilaciones de desarrollo siguen cada commit en main y no son versiones estables; no hay reversión automática.", + "currentCommit": "Commit actual", + "latestCommit": "Último commit", + "updateChannelChanged": "Canal de actualización cambiado", "upToDate": "Actualizado", "xrayStatusUnknown": "Desconocido", "xrayStatusRunning": "En ejecución", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 77cbed0f4..a8be4c071 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -153,6 +153,11 @@ "currentPanelVersion": "نسخه فعلی پنل", "latestPanelVersion": "آخرین نسخه پنل", "panelUpToDate": "پنل به‌روز است", + "devChannel": "کانال توسعه (Dev)", + "devChannelWarning": "بیلدهای توسعه هر کامیت روی main را دنبال می‌کنند و نسخهٔ پایدار نیستند — بازگشت خودکار به نسخهٔ قبلی وجود ندارد.", + "currentCommit": "کامیت فعلی", + "latestCommit": "آخرین کامیت", + "updateChannelChanged": "کانال به‌روزرسانی تغییر کرد", "upToDate": "به‌روز", "xrayStatusUnknown": "ناشناخته", "xrayStatusRunning": "در حال اجرا", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index a88e1f341..14d6209ec 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -153,6 +153,11 @@ "currentPanelVersion": "Versi panel saat ini", "latestPanelVersion": "Versi panel terbaru", "panelUpToDate": "Panel sudah terbaru", + "devChannel": "Kanal dev", + "devChannelWarning": "Build dev mengikuti setiap commit di main dan bukan rilis stabil — tidak ada penurunan versi otomatis.", + "currentCommit": "Commit saat ini", + "latestCommit": "Commit terbaru", + "updateChannelChanged": "Kanal pembaruan diubah", "upToDate": "Terbaru", "xrayStatusUnknown": "Tidak diketahui", "xrayStatusRunning": "Berjalan", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index bc49ffc34..f7fed8891 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -153,6 +153,11 @@ "currentPanelVersion": "現在のパネルバージョン", "latestPanelVersion": "最新のパネルバージョン", "panelUpToDate": "パネルは最新です", + "devChannel": "開発チャンネル", + "devChannelWarning": "開発ビルドは main の各コミットを追跡し、安定版ではありません。自動ダウングレードはありません。", + "currentCommit": "現在のコミット", + "latestCommit": "最新のコミット", + "updateChannelChanged": "更新チャンネルを変更しました", "upToDate": "最新", "xrayStatusUnknown": "不明", "xrayStatusRunning": "実行中", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index b2393b9a4..faed9f191 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -153,6 +153,11 @@ "currentPanelVersion": "Versão atual do painel", "latestPanelVersion": "Última versão do painel", "panelUpToDate": "O painel está atualizado", + "devChannel": "Canal de desenvolvimento", + "devChannelWarning": "As builds de desenvolvimento acompanham cada commit na main e não são versões estáveis — não há downgrade automático.", + "currentCommit": "Commit atual", + "latestCommit": "Último commit", + "updateChannelChanged": "Canal de atualização alterado", "upToDate": "Atualizado", "xrayStatusUnknown": "Desconhecido", "xrayStatusRunning": "Em execução", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index cdc2e4521..478bb5ed5 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -153,6 +153,11 @@ "currentPanelVersion": "Текущая версия панели", "latestPanelVersion": "Последняя версия панели", "panelUpToDate": "Панель обновлена", + "devChannel": "Канал разработки", + "devChannelWarning": "Сборки разработки отслеживают каждый коммит в main и не являются стабильными релизами — автоматического отката нет.", + "currentCommit": "Текущий коммит", + "latestCommit": "Последний коммит", + "updateChannelChanged": "Канал обновления изменён", "upToDate": "Обновлено", "xrayStatusUnknown": "Неизвестно", "xrayStatusRunning": "Запущен", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 76520585c..c11269a1c 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -153,6 +153,11 @@ "currentPanelVersion": "Mevcut panel sürümü", "latestPanelVersion": "Panelin en son sürümü", "panelUpToDate": "Panel güncel", + "devChannel": "Geliştirme kanalı", + "devChannelWarning": "Geliştirme yapıları main üzerindeki her commit'i izler ve kararlı sürüm değildir — otomatik geri alma yoktur.", + "currentCommit": "Geçerli commit", + "latestCommit": "Son commit", + "updateChannelChanged": "Güncelleme kanalı değiştirildi", "upToDate": "Güncel", "xrayStatusUnknown": "Bilinmiyor", "xrayStatusRunning": "Çalışıyor", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index efd3fa52c..c12d86c35 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -153,6 +153,11 @@ "currentPanelVersion": "Поточна версія панелі", "latestPanelVersion": "Остання версія панелі", "panelUpToDate": "Панель оновлено", + "devChannel": "Канал розробки", + "devChannelWarning": "Збірки розробки відстежують кожен коміт у main і не є стабільними релізами — автоматичного відкату немає.", + "currentCommit": "Поточний коміт", + "latestCommit": "Останній коміт", + "updateChannelChanged": "Канал оновлення змінено", "upToDate": "Оновлено", "xrayStatusUnknown": "Невідомо", "xrayStatusRunning": "Запущено", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 86750d01b..4eee99152 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -153,6 +153,11 @@ "currentPanelVersion": "Phiên bản panel hiện tại", "latestPanelVersion": "Phiên bản panel mới nhất", "panelUpToDate": "Panel đã được cập nhật", + "devChannel": "Kênh phát triển", + "devChannelWarning": "Bản dev bám theo từng commit trên main và không phải bản ổn định — không có hạ cấp tự động.", + "currentCommit": "Commit hiện tại", + "latestCommit": "Commit mới nhất", + "updateChannelChanged": "Đã đổi kênh cập nhật", "upToDate": "Đã cập nhật", "xrayStatusUnknown": "Không xác định", "xrayStatusRunning": "Đang chạy", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 696376996..47844d13d 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -153,6 +153,11 @@ "currentPanelVersion": "当前面板版本", "latestPanelVersion": "最新面板版本", "panelUpToDate": "面板已是最新", + "devChannel": "开发通道", + "devChannelWarning": "开发版会跟踪 main 的每次提交,并非稳定版本,且无法自动降级。", + "currentCommit": "当前提交", + "latestCommit": "最新提交", + "updateChannelChanged": "更新通道已切换", "upToDate": "已是最新", "xrayStatusUnknown": "未知", "xrayStatusRunning": "运行中", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 941be76bf..2ea2f4458 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -153,6 +153,11 @@ "currentPanelVersion": "目前面板版本", "latestPanelVersion": "最新面板版本", "panelUpToDate": "面板已是最新", + "devChannel": "開發通道", + "devChannelWarning": "開發版會追蹤 main 的每次提交,並非穩定版本,且無法自動降級。", + "currentCommit": "目前提交", + "latestCommit": "最新提交", + "updateChannelChanged": "更新通道已切換", "upToDate": "已是最新", "xrayStatusUnknown": "未知", "xrayStatusRunning": "運行中", diff --git a/update.sh b/update.sh index 70ff74b86..a5087603c 100755 --- a/update.sh +++ b/update.sh @@ -895,9 +895,16 @@ update_x-ui() { echo -e "${green}Downloading new x-ui version...${plain}" - tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2> /dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') - if [[ ! -n "$tag_version" ]]; then - _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later" + # XUI_UPDATE_TAG lets the panel target a specific release tag (e.g. the + # rolling dev-latest pre-release). Empty keeps the default latest-stable flow. + if [[ -n "${XUI_UPDATE_TAG}" ]]; then + tag_version="${XUI_UPDATE_TAG}" + echo -e "${green}Using update tag: ${tag_version}${plain}" + else + tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2> /dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [[ ! -n "$tag_version" ]]; then + _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later" + fi fi echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..." ${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null