diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e82db870..4f3433e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,6 +70,9 @@ jobs: - arch: arm64 package_command: npm run package:linux:arm64 artifact_name: gsm3-management-panel-linux-arm64-build + - arch: riscv64 + package_command: npm run package:linux:riscv64 + artifact_name: gsm3-management-panel-linux-riscv64-build steps: - uses: actions/checkout@v4 - name: Setup Node.js for build @@ -226,6 +229,11 @@ jobs: with: name: gsm3-management-panel-linux-arm64-build path: linux-arm64-build/ + - name: Download Linux riscv64 artifact + uses: actions/download-artifact@v4 + with: + name: gsm3-management-panel-linux-riscv64-build + path: linux-riscv64-build/ - name: Download Windows artifact uses: actions/download-artifact@v4 with: @@ -238,6 +246,7 @@ jobs: run: | tar -czf gsm3-management-panel-linux.tar.gz -C linux-x64-build . tar -czf gsm3-management-panel-linux-arm64.tar.gz -C linux-arm64-build . + tar -czf gsm3-management-panel-linux-riscv64.tar.gz -C linux-riscv64-build . cd windows-build zip -r ../gsm3-management-panel-windows.zip . - name: Create Release @@ -246,6 +255,7 @@ jobs: files: | gsm3-management-panel-linux.tar.gz gsm3-management-panel-linux-arm64.tar.gz + gsm3-management-panel-linux-riscv64.tar.gz gsm3-management-panel-windows.zip generate_release_notes: true draft: false diff --git a/AGENTS.md b/AGENTS.md index 5b287598..965bc13d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,3 +32,5 @@ const possiblePaths = [ 18. `npm run lint` 只有在仓库存在可用 ESLint 配置时才作为交付检查;如果脚本存在但配置缺失,需要在交付说明中明确说明。 19. 临时诊断脚本、一次性测试文件和调试产物在验证完成后删除;正式测试代码可以保留,但应与现有测试组织方式一致。 20. 开源贡献前确认目标上游和分支基准。当前上游仓库为 `https://github.com/GSManagerXZ/GameServerManager` ,不要仅用个人 fork 的 `origin/main` 判断 PR 是否同步。 +21. 新增架构支持时必须区分完整支持与降级支持。没有官方 Node.js、PTY、Zip-Tools、7z、SteamCMD 或游戏服务端原生资产时,不要伪装成开箱即用;应在打包脚本、安装脚本、CI 和文档中写明系统前置条件、可用功能和不可用功能。 +22. riscv64 目前按系统 Node.js 运行,不内置 Node.js、固定 PTY、Zip-Tools 或 7z 资产。ZIP/7z 功能优先使用系统 `unzip`、`zip`、`7z`/`7zz` 降级;终端 PTY 功能在缺少上游 riscv64 资产时不可用。 diff --git a/README.md b/README.md index cb253005..b664ea38 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,17 @@ sudo apt-get install libc6-dev-arm64-cross ``` 详细的 ARM64 部署说明请参考 [scripts/arm/README.md](scripts/arm/README.md) + +### RISC-V64 架构部署 + +RISC-V64 目前提供 `linux-riscv64` 便携安装包,适合作为 Web 管理面板和架构适配测试节点。该包不内置 Node.js、PTY、Zip-Tools 或 7z 原生资产,目标系统需要预装 Node.js >= 18;ZIP/7z 功能会按需使用系统 `unzip`、`zip`、`7z`/`7zz`。 + +```bash +npm run package:linux:riscv64 +``` + +完整支持边界见 [docs/多架构支持说明.md](docs/多架构支持说明.md)。 + ## 🏗️ 技术架构 ### 前端技术栈 diff --git a/client/src/App.tsx b/client/src/App.tsx index 2746c080..a74ed19f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -185,7 +185,7 @@ function App() { - {/* ARM架构警告弹窗 - 只在已登录时显示 */} + {/* 特殊架构警告弹窗 - 只在已登录时显示 */} diff --git a/client/src/components/ArmWarningModal.tsx b/client/src/components/ArmWarningModal.tsx index ddc5ed2a..5eda6489 100644 --- a/client/src/components/ArmWarningModal.tsx +++ b/client/src/components/ArmWarningModal.tsx @@ -48,10 +48,10 @@ const ArmWarningModal: React.FC = () => {

- ARM架构警告 + 特殊架构警告

- 检测到您当前使用的是ARM架构,此架构目前仍在测试阶段 + 检测到您当前使用的是非 x86_64 架构,此架构目前仍在测试阶段

@@ -82,7 +82,7 @@ const ArmWarningModal: React.FC = () => { 游戏服务端兼容性问题

- ARM架构由于特殊性,Steam等众多单机游戏服务端均无法部署和运行,面板已自动隐藏不受支持的部分 + ARM、RISC-V 等架构由于特殊性,Steam等众多单机游戏服务端均无法部署和运行,面板已自动隐藏不受支持的部分

@@ -112,7 +112,7 @@ const ArmWarningModal: React.FC = () => { 测试阶段风险

- 目前ARM架构面板仍在测试阶段,可能会存在无法预料的问题 + 目前非 x86_64 架构面板仍在测试阶段,可能会存在无法预料的问题

diff --git a/client/src/components/onboarding/SteamCMDOnboardingStep.tsx b/client/src/components/onboarding/SteamCMDOnboardingStep.tsx index 7d219f9b..a5b76ffb 100644 --- a/client/src/components/onboarding/SteamCMDOnboardingStep.tsx +++ b/client/src/components/onboarding/SteamCMDOnboardingStep.tsx @@ -12,6 +12,7 @@ import { AlertCircle, Info } from 'lucide-react' +import { getArchitectureLabel, isLimitedGameServerArchitecture } from '@/utils/architectureSupport' const SteamCMDOnboardingStep: React.FC = () => { const { completeStep } = useOnboardingStore() @@ -108,7 +109,8 @@ const SteamCMDOnboardingStep: React.FC = () => { }) if (!response.ok) { - throw new Error('安装请求失败') + const errorResult = await response.json().catch(() => null) + throw new Error(errorResult?.message || '安装请求失败') } const reader = response.body?.getReader() @@ -223,24 +225,25 @@ const SteamCMDOnboardingStep: React.FC = () => { } const isWindows = systemInfo?.platform?.toLowerCase().includes('windows') - const isArmArchitecture = systemInfo?.arch === 'arm64' || systemInfo?.arch === 'aarch64' + const hasLimitedGameServerSupport = isLimitedGameServerArchitecture(systemInfo) + const architectureLabel = getArchitectureLabel(systemInfo) - // ARM架构下自动完成步骤 + // 非 x86_64 架构下自动完成步骤 useEffect(() => { - if (isArmArchitecture) { + if (hasLimitedGameServerSupport) { // 延迟一下让用户看到提示信息 const timer = setTimeout(() => { completeStep('steamcmd') }, 2000) return () => clearTimeout(timer) } - }, [isArmArchitecture, completeStep]) + }, [hasLimitedGameServerSupport, completeStep]) - // 如果是ARM架构,显示不支持的提示 - if (isArmArchitecture) { + // 如果是非 x86_64 架构,显示不支持的提示 + if (hasLimitedGameServerSupport) { return (
- {/* ARM架构不支持提示 */} + {/* 非 x86_64 架构不支持提示 */}
@@ -248,14 +251,14 @@ const SteamCMDOnboardingStep: React.FC = () => {

- ARM架构检测 + 特殊架构检测

- 检测到您当前使用的是ARM架构({systemInfo?.arch}),SteamCMD目前不支持ARM架构。 + 检测到您当前使用的是 {architectureLabel} 架构,SteamCMD目前不支持该架构。

• SteamCMD仅支持x86_64架构

-

• ARM架构下将自动跳过SteamCMD配置

+

• 当前架构下将自动跳过SteamCMD配置

• 您仍可以使用其他游戏部署功能,如Minecraft服务器

diff --git a/client/src/pages/EnvironmentManagerPage.tsx b/client/src/pages/EnvironmentManagerPage.tsx index cb663660..e92f9815 100644 --- a/client/src/pages/EnvironmentManagerPage.tsx +++ b/client/src/pages/EnvironmentManagerPage.tsx @@ -14,7 +14,6 @@ import { } from 'lucide-react' import { useNotificationStore } from '@/stores/notificationStore' import apiClient from '@/utils/api' -import { hasSponsorKey } from '@/utils/sponsor' import socketClient from '@/utils/socket' import { copyToClipboard } from '@/utils/clipboard' @@ -30,6 +29,76 @@ interface JavaEnvironment { installStage?: 'download' | 'extract' } +interface JavaCatalogVersion { + id: string + major: number + label: string + description: string + presets: string[] + channels: JavaReleaseChannel[] + defaultChannel: JavaReleaseChannel +} + +interface JavaCatalogPreset { + id: string + label: string + description: string + versions: string[] +} + +interface JavaCatalogOption { + id: string + version: string + versionLabel: string + major: number + provider: JavaCatalogProviderId + providerLabel: string + providerDescription: string + source: 'download' | 'package-manager' + releaseChannel: JavaReleaseChannel + releaseChannelLabel: string + available: boolean + recommended: boolean + sponsorOnly?: boolean + packageManager?: string + packageName?: string + unsupportedReason?: string + presets: string[] +} + +interface JavaDownloadCatalog { + platform: string + arch: string + platformKey?: string + sponsorAvailable: boolean + providers: JavaCatalogProvider[] + presets: JavaCatalogPreset[] + versions: JavaCatalogVersion[] + options: JavaCatalogOption[] + custom: { + defaultMajor: number + defaultChannel: JavaReleaseChannel + minMajor: number + maxMajor: number + providers: JavaDownloadProviderId[] + channels: JavaReleaseChannel[] + } +} + +type JavaReleaseChannel = 'ga' | 'ea' +type JavaDownloadProviderId = 'sponsor' | 'adoptium' | 'azul' +type JavaCatalogProviderId = JavaDownloadProviderId | 'system' + +interface JavaCatalogProvider { + id: JavaCatalogProviderId + label: string + description: string + source: 'download' | 'package-manager' + sponsorOnly?: boolean + supportsCustomVersion?: boolean + supportedChannels?: JavaReleaseChannel[] +} + interface LocalSystemInfo { platform: string rawPlatform?: string // 原始平台标识 (win32/linux/darwin) @@ -84,16 +153,6 @@ interface DirectXEnvironment { installStage?: 'download' | 'install' } -// Java版本下载配置:部分版本未提供对应平台(如 ARM64)的安装包 -interface JavaVersionConfig { - version: string - key: string - description: string - windows?: string - linux?: string - arm?: string -} - const EnvironmentManagerPage: React.FC = () => { const [systemInfo, setSystemInfo] = useState(null) const [javaEnvironments, setJavaEnvironments] = useState([]) @@ -107,6 +166,11 @@ const EnvironmentManagerPage: React.FC = () => { const [showTaskProgress, setShowTaskProgress] = useState(false) const [taskProgressAnimating, setTaskProgressAnimating] = useState(false) const [packagesLoading, setPackagesLoading] = useState(false) + const [javaCatalog, setJavaCatalog] = useState(null) + const [selectedJavaOptions, setSelectedJavaOptions] = useState>({}) + const [customJavaMajor, setCustomJavaMajor] = useState('') + const [customJavaProvider, setCustomJavaProvider] = useState('adoptium') + const [customJavaChannel, setCustomJavaChannel] = useState('ga') const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [activeTab, setActiveTab] = useState('java') @@ -129,7 +193,7 @@ const EnvironmentManagerPage: React.FC = () => { const { addNotification } = useNotificationStore() - // 赞助者状态(赞助者密钥目前仅本地记录,Java 环境下载会按密钥走赞助者通道) + // 赞助者状态 const [sponsorStatus, setSponsorStatus] = useState<{ hasKey: boolean loading: boolean @@ -138,54 +202,112 @@ const EnvironmentManagerPage: React.FC = () => { loading: true }) - // Java版本下载配置:部分版本未提供 ARM64 安装包 - const javaVersions: JavaVersionConfig[] = [ - { - version: 'Java 8', - key: 'java8', - description: 'Java 8 (OpenJDK 8u44)', - windows: 'https://download.xiaozhuhouses.asia/d/e2fb4833ba415b8c500aed2d9b88d401/openjdk-8u44-windows-i586.zip', - linux: 'https://download.xiaozhuhouses.asia/d/eba19d9b0eb2f5ee0af4aa1410e7e8ea/openjdk-8u44-linux-x64.tar.gz' - }, - { - version: 'Java 11', - key: 'java11', - description: 'Java 11 (OpenJDK 11.0.0.2)', - windows: 'https://download.xiaozhuhouses.asia/d/09844c5699181cace0c50838a01b3afa/openjdk-11.0.0.2_windows-x64.zip', - linux: 'https://download.xiaozhuhouses.asia/d/1ad2d19275d387bf474186213159971b/openjdk-11.0.0.2_linux-x64.tar.gz' - }, - { - version: 'Java 17', - key: 'java17', - description: 'Java 17 (OpenJDK 17.0.0.1)', - windows: 'https://download.xiaozhuhouses.asia/d/7dc046a7855530363fac794781bdf767/openjdk-17.0.0.1+2_windows-x64_bin.zip', - linux: 'https://download.xiaozhuhouses.asia/d/25b171ae761d3222bd0f91ddca373d32/openjdk-17.0.0.1+2_linux-x64_bin.tar.gz', - arm: 'https://download.xiaozhuhouses.asia/d/8868121de8e1a36192abfe4034ec3a7b/openjdk-17.0.2_linux-aarch64_bin.tar.gz' - }, - { - version: 'Java 21', - key: 'java21', - description: 'Java 21 (OpenJDK 21)', - windows: 'https://download.xiaozhuhouses.asia/d/a436ba580cf68550b0a14408105eb8d5/openjdk-21+35_windows-x64_bin.zip', - linux: 'https://download.xiaozhuhouses.asia/d/521a8c9551a8cd0923825789e3be5054/openjdk-21+35_linux-x64_bin.tar.gz', - arm: 'https://download.xiaozhuhouses.asia/d/4c5d060a6186d630f4c0cb64cac7c075/openjdk-21_linux-aarch64_bin.tar.gz' - }, - { - version: 'Java 25', - key: 'java25', - description: 'Java 25 (OpenJDK 25+36)', - windows: 'https://download.xiaozhuhouses.asia/d/1e1b2424fef706eb9f13851ad9081aff/openjdk-25+36_windows-x64_bin.zip', - linux: 'https://download.xiaozhuhouses.asia/d/59e59a84f693481bb7a29802a2ef5253/openjdk-25+36_linux-x64_bin.tar.gz', - arm: 'https://download.xiaozhuhouses.asia/d/a6f7a6c56d31bcb9c47927fb55386343/openjdk-25.0.2_linux-aarch64_bin.tar.gz' - }, - { - version: 'Java 27', - key: 'java27', - description: 'Java 27 (OpenJDK 27+35)', - windows: 'https://download.xiaozhuhouses.asia/d/b1b2ce98fd714e8202a6d0cd8893237e/openjdk-27+35_windows-x64_bin.zip', - linux: 'https://download.xiaozhuhouses.asia/d/57849c8d615de1b355d88ba2fa2d6bf2/openjdk-27+35_linux-x64_bin.tar.gz' + const getJavaVersionOptions = (version: string): JavaCatalogOption[] => ( + javaCatalog?.options.filter(option => option.version === version) || [] + ) + + const getDefaultJavaOption = (version: string): JavaCatalogOption | null => { + const options = getJavaVersionOptions(version) + return ( + options.find(option => option.available && option.source === 'download' && option.recommended) || + options.find(option => option.available && option.source === 'download') || + options.find(option => option.available && option.source === 'package-manager' && option.recommended) || + options.find(option => option.available) || + options[0] || + null + ) + } + + const getSelectedJavaOption = (version: string): JavaCatalogOption | null => { + const options = getJavaVersionOptions(version) + const selectedOptionId = selectedJavaOptions[version] + return options.find(option => option.id === selectedOptionId) || getDefaultJavaOption(version) + } + + const getPresetLabels = (presetIds: string[]): string[] => ( + presetIds + .map(presetId => javaCatalog?.presets.find(preset => preset.id === presetId)?.label) + .filter((label): label is string => Boolean(label)) + ) + + const getProviderLabel = (providerId: string): string => ( + javaCatalog?.providers.find(provider => provider.id === providerId)?.label || providerId + ) + + const getChannelLabel = (channel: JavaReleaseChannel): string => ( + channel === 'ea' ? 'EA / 预览版' : 'GA / 稳定版' + ) + + const getCustomJavaMajorNumber = (): number | null => { + const major = Number(customJavaMajor) + const minMajor = javaCatalog?.custom.minMajor ?? 8 + const maxMajor = javaCatalog?.custom.maxMajor ?? 99 + if (!Number.isInteger(major) || major < minMajor || major > maxMajor) { + return null } - ] + + return major + } + + const getCustomJavaVersionId = (): string | null => { + const major = getCustomJavaMajorNumber() + if (major === null) return null + return customJavaChannel === 'ea' ? `java${major}-ea` : `java${major}` + } + + const getCustomJavaLabel = (): string => { + const major = getCustomJavaMajorNumber() + return major === null ? '自定义 Java' : `Java ${major}${customJavaChannel === 'ea' ? ' EA' : ''}` + } + + const getCustomJavaUnsupportedMessage = (): string | null => { + const major = getCustomJavaMajorNumber() + if (major === null) { + const minMajor = javaCatalog?.custom.minMajor ?? 8 + const maxMajor = javaCatalog?.custom.maxMajor ?? 99 + return `请输入 ${minMajor}-${maxMajor} 之间的 Java 主版本号` + } + + if (!javaCatalog?.custom.providers.includes(customJavaProvider)) { + return '当前下载源不支持自定义 Java 版本' + } + + if (!javaCatalog?.custom.channels.includes(customJavaChannel)) { + return '当前通道不支持自定义 Java 版本' + } + + if (customJavaProvider === 'azul' && javaCatalog?.platformKey === 'riscv64') { + return 'Azul Zulu 暂不支持当前 riscv64 架构,请改用 Eclipse Temurin 或系统包管理器' + } + + return null + } + + const updateJavaInstallState = ( + version: string, + patch: Partial + ) => { + setJavaEnvironments(prev => { + let matched = false + const next = prev.map(env => { + if (env.version !== version) return env + matched = true + return { ...env, ...patch } + }) + + if (!matched) { + next.push({ + version, + platform: systemInfo?.platform || '', + downloadUrl: '', + installed: false, + ...patch + }) + } + + return next + }) + } // 获取系统信息 const fetchSystemInfo = async () => { @@ -208,17 +330,23 @@ const EnvironmentManagerPage: React.FC = () => { } } - // 获取赞助者状态(是否有本地记录的密钥,判定统一由 hasSponsorKey 收口) + // 获取赞助者状态 const fetchSponsorStatus = async () => { try { setSponsorStatus(prev => ({ ...prev, loading: true })) const response = await apiClient.getSponsorKeyInfo() - const keyInfo = response.success ? response.data : null - setSponsorStatus({ - hasKey: hasSponsorKey(keyInfo), - loading: false - }) + if (response.success && response.data) { + setSponsorStatus({ + hasKey: Boolean(response.data.key), + loading: false + }) + } else { + setSponsorStatus({ + hasKey: false, + loading: false + }) + } } catch (error) { console.error('获取赞助者状态失败:', error) setSponsorStatus({ @@ -228,32 +356,53 @@ const EnvironmentManagerPage: React.FC = () => { } } - // 按当前系统平台与架构解析Java下载地址,未提供对应安装包时返回 null - const resolveJavaDownloadUrl = (javaConfig: JavaVersionConfig): string | null => { - if (!systemInfo) { - return null - } + // 获取Java环境列表 + const fetchJavaEnvironments = async (force = false) => { + if (tabDataLoaded.java && !force) return - if (systemInfo.platform === 'win32') { - return javaConfig.windows || null - } + setTabLoadingStates(prev => ({ ...prev, java: true })) + try { + const [environmentResult, catalogResult] = await Promise.allSettled([ + apiClient.getJavaEnvironments(), + apiClient.getJavaDownloadCatalog() + ]) + let environmentsLoaded = false + let catalogLoaded = false - if (systemInfo.arch === 'arm64' || systemInfo.arch === 'aarch64') { - return javaConfig.arm || null - } + if (environmentResult.status === 'fulfilled' && environmentResult.value.success && environmentResult.value.data) { + setJavaEnvironments(environmentResult.value.data) + environmentsLoaded = true + } - return javaConfig.linux || null - } + if (catalogResult.status === 'fulfilled' && catalogResult.value.success && catalogResult.value.data) { + const catalog = catalogResult.value.data as JavaDownloadCatalog + setJavaCatalog(catalog) + setCustomJavaMajor(prev => prev || String(catalog.custom.defaultMajor)) + setSelectedJavaOptions(prev => { + const next = { ...prev } + + for (const version of catalog.versions) { + const options = catalog.options.filter(option => option.version === version.id) + const current = options.find(option => option.id === next[version.id]) + const fallback = ( + options.find(option => option.available && option.source === 'download' && option.recommended) || + options.find(option => option.available && option.source === 'download') || + options.find(option => option.available && option.source === 'package-manager' && option.recommended) || + options.find(option => option.available) || + options[0] + ) + + if (!current && fallback) { + next[version.id] = fallback.id + } + } - // 获取Java环境列表 - const fetchJavaEnvironments = async () => { - if (tabDataLoaded.java) return + return next + }) + catalogLoaded = true + } - setTabLoadingStates(prev => ({ ...prev, java: true })) - try { - const response = await apiClient.getJavaEnvironments() - if (response.success && response.data) { - setJavaEnvironments(response.data) + if (environmentsLoaded || catalogLoaded) { setTabDataLoaded(prev => ({ ...prev, java: true })) } } catch (error) { @@ -439,7 +588,7 @@ const EnvironmentManagerPage: React.FC = () => { }) if (data.success) { - fetchJavaEnvironments() + fetchJavaEnvironments(true) } } @@ -659,7 +808,7 @@ const EnvironmentManagerPage: React.FC = () => { case 'java': // 重置加载状态,强制重新加载 setTabDataLoaded(prev => ({ ...prev, java: false })) - await fetchJavaEnvironments() + await fetchJavaEnvironments(true) break case 'vcredist': if (systemInfo?.platform === 'win32') { @@ -688,49 +837,77 @@ const EnvironmentManagerPage: React.FC = () => { } // 安装Java环境 - const handleInstallJava = async (version: string) => { - if (!systemInfo) { + const handleInstallJava = async ( + version: string, + override?: { + label: string + provider: JavaDownloadProviderId + releaseChannel: JavaReleaseChannel + unsupportedMessage?: string | null + } + ) => { + const javaVersion = javaCatalog?.versions.find(item => item.id === version) + const selectedOption = override ? null : getSelectedJavaOption(version) + const displayLabel = override?.label || javaVersion?.label || version + + if (!override && (!javaVersion || !selectedOption)) { addNotification({ type: 'error', title: '错误', - message: '系统信息未加载' + message: '未找到Java版本配置' }) return } - const javaConfig = javaVersions.find(v => v.key === version) - if (!javaConfig) { + if (override?.unsupportedMessage) { addNotification({ type: 'error', title: '错误', - message: '未找到Java版本配置' + message: override.unsupportedMessage }) return } - // 根据平台和架构选择下载URL - const downloadUrl = resolveJavaDownloadUrl(javaConfig) - if (!downloadUrl) { + if (selectedOption && !selectedOption.available) { addNotification({ - type: 'warning', - title: '暂不支持', - message: `${javaConfig.version} 暂未提供当前架构(${systemInfo.arch})的安装包,请选择其它版本` + type: 'error', + title: '错误', + message: selectedOption.unsupportedReason || '当前平台暂不支持该 Java 安装选项' + }) + return + } + + if (selectedOption?.source === 'package-manager') { + if (selectedOption.packageManager && selectedOption.packageName) { + setActiveTab('packages') + setSelectedPackageManager(selectedOption.packageManager) + setSelectedPackages(prev => ( + prev.includes(selectedOption.packageName!) + ? prev + : [...prev, selectedOption.packageName!] + )) + await fetchPackageManagers() + await fetchPackages(selectedOption.packageManager) + } + + addNotification({ + type: 'info', + title: '系统包预设', + message: selectedOption.packageName + ? `已切换到动态链接库,请安装 ${selectedOption.packageName}` + : '请通过系统包管理器安装 OpenJDK' }) return } try { // 更新安装状态 - setJavaEnvironments(prev => prev.map(env => - env.version === version - ? { ...env, installing: true, installProgress: 0 } - : env - )) + updateJavaInstallState(version, { installing: true, installProgress: 0 }) const response = await apiClient.installJavaEnvironment({ version, - downloadUrl, - platform: systemInfo.platform, + provider: override?.provider || selectedOption?.provider, + releaseChannel: override?.releaseChannel || selectedOption?.releaseChannel, socketId: socketClient.getId() }) @@ -738,28 +915,20 @@ const EnvironmentManagerPage: React.FC = () => { addNotification({ type: 'error', title: '错误', - message: `${javaConfig.version} 启动安装失败: ${response.message}` + message: `${displayLabel} 启动安装失败: ${response.message}` }) // 重置安装状态 - setJavaEnvironments(prev => prev.map(env => - env.version === version - ? { ...env, installing: false, installProgress: 0 } - : env - )) + updateJavaInstallState(version, { installing: false, installProgress: 0 }) } } catch (error) { console.error('安装Java环境失败:', error) addNotification({ type: 'error', title: '错误', - message: `${javaConfig.version} 安装失败` + message: `${displayLabel} 安装失败` }) // 重置安装状态 - setJavaEnvironments(prev => prev.map(env => - env.version === version - ? { ...env, installing: false, installProgress: 0 } - : env - )) + updateJavaInstallState(version, { installing: false, installProgress: 0 }) } } @@ -773,7 +942,7 @@ const EnvironmentManagerPage: React.FC = () => { title: '成功', message: `${version} 卸载成功` }) - await fetchJavaEnvironments() + await fetchJavaEnvironments(true) } else { addNotification({ type: 'error', @@ -1301,7 +1470,7 @@ const EnvironmentManagerPage: React.FC = () => { : 'text-yellow-800 dark:text-yellow-200' }`}> {sponsorStatus.hasKey ? ( - '已记录赞助者密钥,Java环境将通过赞助者专用通道下载(密钥无效时自动回退普通通道)' + '已记录本地赞助者密钥,赞助高速源会在下载时尝试启用专用通道' ) : ( <> Java环境安装现已支持赞助者专享国内高速服务器下载,您当前还不是赞助者,欢迎前往 @@ -1425,133 +1594,376 @@ const EnvironmentManagerPage: React.FC = () => {

正在加载Java环境信息...

) : ( -
- {javaVersions.map((javaConfig) => { - const env = javaEnvironments.find(e => e.version === javaConfig.key) - const isInstalled = env?.installed || false - const isInstalling = env?.installing || false - // 当前系统架构下是否提供了安装包(系统信息未加载完成时不限制) - const archSupported = !systemInfo || !!resolveJavaDownloadUrl(javaConfig) - - return ( -
- {/* 卡片头部 */} -
-
- -

- {javaConfig.version} -

-
-
- {isInstalled ? ( - <> - - 已安装 - - ) : ( - <> - - 未安装 - - )} -
-
+ javaCatalog ? ( +
+ {(() => { + const customVersionId = getCustomJavaVersionId() + const customEnv = customVersionId + ? javaEnvironments.find(env => env.version === customVersionId) + : undefined + const customUnsupportedMessage = getCustomJavaUnsupportedMessage() + const customProviderOptions = javaCatalog.providers.filter(provider => ( + provider.source === 'download' && + provider.supportsCustomVersion && + javaCatalog.custom.providers.includes(provider.id as JavaDownloadProviderId) + )) + const isInstalled = customEnv?.installed || false + const isInstalling = customEnv?.installing || false + + return ( +
+ +
+ +

+ 高级:自定义 Java 版本 +

+
+
+ {isInstalled ? ( + <> + + 已安装 + + ) : ( + <> + + 未安装 + + )} +
+
+ +
+
+ + setCustomJavaMajor(event.target.value)} + disabled={isInstalling} + className="w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + /> +
+
+ + +
+
+ + +
+
+ {!isInstalled ? ( + + ) : ( + + )} +
+
- {/* 描述 */} -

- {javaConfig.description} -

+
+ {getProviderLabel(customJavaProvider)} · {getChannelLabel(customJavaChannel)} · {getCustomJavaLabel()} +
- {/* 当前架构无安装包提示 */} - {!isInstalled && !archSupported && ( -

- 该版本暂未提供当前架构({systemInfo?.arch || '未知'})的安装包 -

- )} + {customUnsupportedMessage && ( +
+ + {customUnsupportedMessage} +
+ )} - {/* 安装进度 */} - {isInstalling && ( -
-
- - {env?.installStage === 'download' ? '正在下载...' : - env?.installStage === 'extract' ? '正在解压...' : '正在安装...'} - - {Math.round(env?.installProgress || 0)}% -
-
-
-
-
- {env?.installStage === 'download' ? '下载阶段 (70%)' : - env?.installStage === 'extract' ? '解压阶段 (30%)' : ''} -
-
- )} + {isInstalling && ( +
+
+ + {customEnv?.installStage === 'download' ? '正在下载...' : + customEnv?.installStage === 'extract' ? '正在解压...' : '正在安装...'} + + {Math.round(customEnv?.installProgress || 0)}% +
+
+
+
+
+ )} - {/* Java路径 */} - {isInstalled && env?.javaExecutable && ( -
-

启动命令:

-
- {env.javaExecutable} -
- +
+ )} +
+ ) + })()} + +
+ {javaCatalog.versions.filter(javaVersion => javaVersion.defaultChannel === 'ga').map((javaVersion) => { + const env = javaEnvironments.find(e => e.version === javaVersion.id) + const isInstalled = env?.installed || false + const isInstalling = env?.installing || false + const options = getJavaVersionOptions(javaVersion.id) + const selectedOption = getSelectedJavaOption(javaVersion.id) + const unsupportedMessage = !isInstalled && selectedOption && !selectedOption.available + ? selectedOption.unsupportedReason + : null + const presetLabels = getPresetLabels(javaVersion.presets) + + return ( +
- - 复制路径 - -
- )} + {/* 卡片头部 */} +
+
+ +

+ {javaVersion.label} +

+
+
+ {isInstalled ? ( + <> + + 已安装 + + ) : ( + <> + + 未安装 + + )} +
+
- {/* 操作按钮 */} -
- {!isInstalled ? ( - - ) : ( - - )} + + {!isInstalled && options.length > 0 && ( +
+ + + {selectedOption && ( +

+ {selectedOption.source === 'package-manager' && selectedOption.packageName + ? `${selectedOption.providerDescription}:${selectedOption.packageName}` + : `${selectedOption.providerDescription} · ${selectedOption.releaseChannelLabel}`} +

+ )} +
+ )} + + {unsupportedMessage && ( +
+ + {unsupportedMessage} +
+ )} + + {/* 安装进度 */} + {isInstalling && ( +
+
+ + {env?.installStage === 'download' ? '正在下载...' : + env?.installStage === 'extract' ? '正在解压...' : '正在安装...'} + + {Math.round(env?.installProgress || 0)}% +
+
+
+
+
+ {env?.installStage === 'download' ? '下载阶段 (70%)' : + env?.installStage === 'extract' ? '解压阶段 (30%)' : ''} +
+
+ )} + + {/* Java路径 */} + {isInstalled && env?.javaExecutable && ( +
+

启动命令:

+
+ {env.javaExecutable} +
+ +
+ )} + + {/* 操作按钮 */} +
+ {!isInstalled ? ( + + ) : ( + + )} +
+
+ ) + })}
+ ) : ( +
+ +

Java下载目录未加载,请刷新后重试

+
) - })} -
)} )} diff --git a/client/src/pages/GameDeploymentPage.tsx b/client/src/pages/GameDeploymentPage.tsx index 37ef867b..2b96a690 100644 --- a/client/src/pages/GameDeploymentPage.tsx +++ b/client/src/pages/GameDeploymentPage.tsx @@ -35,6 +35,7 @@ import ConfirmInstanceUpdateDialog from '@/components/ConfirmInstanceUpdateDialo import FileDeploymentConflictDialog from '@/components/FileDeploymentConflictDialog' import NetworkStatusBanner from '@/components/NetworkStatusBanner' import SteamBranchSelector from '@/components/SteamBranchSelector' +import { isLimitedGameServerArchitecture } from '@/utils/architectureSupport' interface GameInfo { game_nameCN: string @@ -2543,9 +2544,9 @@ const GameDeploymentPage: React.FC = () => { } }, [activeTab, sponsorKeyValid, defaultGamePath, fetchSystemInfo]) - // 当检测到ARM架构时,如果当前标签页是不支持的标签页,则切换到minecraft标签页 + // 当检测到非 x86_64 架构时,如果当前标签页是不支持的标签页,则切换到 minecraft 标签页 useEffect(() => { - if (systemInfo && (systemInfo.arch === 'arm64' || systemInfo.arch === 'aarch64')) { + if (isLimitedGameServerArchitecture(systemInfo)) { const unsupportedTabs = ['steamcmd', 'more-games', 'online-deploy'] if (unsupportedTabs.includes(activeTab)) { setActiveTab('minecraft') @@ -3693,8 +3694,8 @@ const GameDeploymentPage: React.FC = () => { // 检查是否有任何游戏包含type信息 const hasGameTypes = availableOnlineGameTypes.length > 0 - // 检查是否为ARM架构,如果是则隐藏SteamCMD、更多游戏部署和在线部署标签页 - const isArmArchitecture = systemInfo?.arch === 'arm64' || systemInfo?.arch === 'aarch64' + // 非 x86_64 架构隐藏 SteamCMD、更多游戏部署和在线部署标签页 + const hasLimitedGameServerSupport = isLimitedGameServerArchitecture(systemInfo) const renderFileDeploySection = () => (
@@ -4362,13 +4363,13 @@ const GameDeploymentPage: React.FC = () => { ) const tabs = [ - // 只有在非ARM架构时才显示SteamCMD标签页 - ...(isArmArchitecture ? [] : [{ id: 'steamcmd', name: 'SteamCMD', icon: Download }]), + // 只有在 x86_64 架构时才显示SteamCMD标签页 + ...(hasLimitedGameServerSupport ? [] : [{ id: 'steamcmd', name: 'SteamCMD', icon: Download }]), { id: 'minecraft', name: 'Minecraft部署', icon: Pickaxe }, { id: 'mrpack', name: 'Minecraft整合包部署', icon: Package }, { id: 'file-deploy', name: '文件部署', icon: Archive }, - // 只有在非ARM架构时才显示更多游戏部署和在线部署标签页 - ...(isArmArchitecture ? [] : [ + // 只有在 x86_64 架构时才显示更多游戏部署和在线部署标签页 + ...(hasLimitedGameServerSupport ? [] : [ { id: 'more-games', name: '更多游戏部署', icon: Server }, { id: 'online-deploy', name: '在线部署', icon: ExternalLink } ]) diff --git a/client/src/pages/InstanceManagerPage.tsx b/client/src/pages/InstanceManagerPage.tsx index 8b03ee7e..ab369db0 100644 --- a/client/src/pages/InstanceManagerPage.tsx +++ b/client/src/pages/InstanceManagerPage.tsx @@ -35,6 +35,7 @@ import RconConsole from '@/components/RconConsole' import ConfirmDialog from '@/components/ConfirmDialog' import SteamBranchSelector from '@/components/SteamBranchSelector' import { formatFileSize } from '@/utils/format' +import { isLimitedGameServerArchitecture } from '@/utils/architectureSupport' import { io, Socket } from 'socket.io-client' import config from '@/config' @@ -594,7 +595,7 @@ const InstanceManagerPage: React.FC = () => { fetchInstances() fetchAvailableConfigs() fetchSystemUsers() - fetchSystemInfo() // 获取系统信息以检测ARM架构 + fetchSystemInfo() // 获取系统信息以检测特殊架构 // 检查是否有待创建的实例(从游戏部署页面跳转过来) const pendingInstanceStr = localStorage.getItem('pendingInstance') @@ -713,9 +714,9 @@ const InstanceManagerPage: React.FC = () => { } } - // 当检测到ARM架构时,如果当前标签页是实例市场,则切换到我的实例标签页 + // 当检测到非 x86_64 架构时,如果当前标签页是实例市场,则切换到我的实例标签页 useEffect(() => { - if (systemInfo && (systemInfo.arch === 'arm64' || systemInfo.arch === 'aarch64') && activeTab === 'market') { + if (isLimitedGameServerArchitecture(systemInfo) && activeTab === 'market') { setActiveTab('instances') } }, [systemInfo, activeTab]) @@ -1589,8 +1590,8 @@ const InstanceManagerPage: React.FC = () => { 我的实例
- {/* 实例市场标签页 - 检测到ARM架构时隐藏 */} - {!(systemInfo?.arch === 'arm64' || systemInfo?.arch === 'aarch64') && ( + {/* 实例市场标签页 - 非 x86_64 架构时隐藏 */} + {!isLimitedGameServerArchitecture(systemInfo) && (
- {/* SteamCMD设置 - 检测到ARM架构时隐藏 */} - {!(systemInfo?.arch === 'arm64' || systemInfo?.arch === 'aarch64') && ( + {/* SteamCMD设置 - 非 x86_64 架构时隐藏 */} + {!isLimitedGameServerArchitecture(systemInfo) && (
diff --git a/client/src/stores/authStore.ts b/client/src/stores/authStore.ts index c158b368..ed5956a4 100644 --- a/client/src/stores/authStore.ts +++ b/client/src/stores/authStore.ts @@ -5,6 +5,7 @@ import apiClient from '@/utils/api' import socketClient from '@/utils/socket' import { useOnboardingStore } from './onboardingStore' import { useArmWarningStore } from '@/stores/armWarningStore' +import { isLimitedGameServerArchitecture } from '@/utils/architectureSupport' interface AuthStore extends AuthState { // 控制是否在登录过期时自动跳转到登录页面 @@ -62,7 +63,7 @@ export const useAuthStore = create()( }, 1000) } - // 检测ARM架构并显示警告(仅限一次) + // 检测特殊架构并显示警告(仅限一次) setTimeout(() => { get().checkArmArchitecture() }, 1500) @@ -258,7 +259,7 @@ export const useAuthStore = create()( checkArmArchitecture: async () => { try { - // 检查是否已经显示过ARM架构警告 + // 检查是否已经显示过特殊架构警告 const hasShownArmWarning = localStorage.getItem('gsm3_arm_warning_shown') if (hasShownArmWarning === 'true') { return @@ -269,9 +270,7 @@ export const useAuthStore = create()( if (response.success && response.data) { const { arch } = response.data - // 检测ARM架构 (arm64, aarch64, arm) - if (arch && (arch.includes('arm') || arch.includes('aarch64'))) { - // 显示ARM架构警告 + if (isLimitedGameServerArchitecture({ arch })) { const armWarningStore = useArmWarningStore.getState() armWarningStore.showWarning() @@ -280,7 +279,7 @@ export const useAuthStore = create()( } } } catch (error) { - console.error('检测ARM架构失败:', error) + console.error('检测特殊架构失败:', error) } }, }), @@ -294,4 +293,4 @@ export const useAuthStore = create()( }), } ) -) \ No newline at end of file +) diff --git a/client/src/stores/onboardingStore.ts b/client/src/stores/onboardingStore.ts index 774e0581..d7113524 100644 --- a/client/src/stores/onboardingStore.ts +++ b/client/src/stores/onboardingStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { SystemInfo } from '@/types' +import { isLimitedGameServerArchitecture } from '@/utils/architectureSupport' export interface OnboardingStep { id: string @@ -150,13 +151,10 @@ export const useOnboardingStore = create()( }, updateStepsForSystem: (systemInfo: SystemInfo | null) => { - // 检查是否为ARM架构 - const isArmArchitecture = systemInfo?.arch === 'arm64' || systemInfo?.arch === 'aarch64' - let filteredSteps = [...defaultSteps] - // 如果是ARM架构,过滤掉SteamCMD步骤 - if (isArmArchitecture) { + // 非 x86_64 架构过滤掉SteamCMD步骤 + if (isLimitedGameServerArchitecture(systemInfo)) { filteredSteps = defaultSteps.filter(step => step.id !== 'steamcmd') } diff --git a/client/src/utils/api.ts b/client/src/utils/api.ts index c36e989e..737d7e10 100644 --- a/client/src/utils/api.ts +++ b/client/src/utils/api.ts @@ -924,10 +924,16 @@ class ApiClient { return this.get('/environment/java') } + async getJavaDownloadCatalog() { + return this.get('/environment/java/catalog') + } + async installJavaEnvironment(data: { version: string - downloadUrl: string - platform: string + provider?: string + releaseChannel?: 'ga' | 'ea' + downloadUrl?: string + platform?: string socketId?: string }) { return this.post('/environment/java/install', data) diff --git a/client/src/utils/architectureSupport.ts b/client/src/utils/architectureSupport.ts new file mode 100644 index 00000000..558230a6 --- /dev/null +++ b/client/src/utils/architectureSupport.ts @@ -0,0 +1,16 @@ +import type { SystemInfo } from '@/types' + +export const normalizeArchitecture = (arch?: string): string => (arch || '').toLowerCase() + +export const isX64Architecture = (arch?: string): boolean => { + const normalizedArch = normalizeArchitecture(arch) + return normalizedArch === 'x64' || normalizedArch === 'x86_64' || normalizedArch === 'amd64' +} + +export const isLimitedGameServerArchitecture = (systemInfo?: Pick | null): boolean => { + return Boolean(systemInfo?.arch) && !isX64Architecture(systemInfo?.arch) +} + +export const getArchitectureLabel = (systemInfo?: Pick | null): string => { + return systemInfo?.arch || '未知架构' +} diff --git a/docs/7z-support.md b/docs/7z-support.md index a39e8ec1..174ef538 100644 --- a/docs/7z-support.md +++ b/docs/7z-support.md @@ -49,6 +49,7 @@ - **已存在**:跳过下载,记录日志 `7z 已就绪` - **不存在**:自动从 GitHub Releases 下载对应平台的二进制文件 +- **暂无内置资产的平台**:尝试使用系统 `7z` 或 `7zz` 降级 - **下载失败**:记录警告日志但不阻塞服务启动,7z 相关功能降级为不可用 ### 下载地址 @@ -80,6 +81,7 @@ | Windows | ARM64 | `7z_win32_arm64.exe` | | Linux | x64 | `7z_linux_x64` | | Linux | ARM64 | `7z_linux_arm64` | +| Linux | RISC-V64 | 暂无内置资产,使用系统 `7z`/`7zz` 降级 | | Linux | ia32 | `7z_linux_386` | | Linux | ARM | `7z_linux_arm` | | macOS | x64 | `7z_darwin_x64` | @@ -93,6 +95,8 @@ 打包脚本(`scripts/package.js`)在构建时会自动从 GitHub Releases 下载目标平台的 7z 二进制文件并内置到产物中,与 `file_zip` 的下载流程并列执行,确保用户部署后无需额外下载。 +`linux-riscv64` 暂无内置 7z 资产,打包时会跳过该二进制。目标系统需要安装 `7z` 或 `7zz` 后才能使用 7z 压缩/解压功能。 + ### Docker 镜像 Dockerfile 在构建阶段会根据目标架构(`TARGETARCH`)自动下载对应的 7z 二进制文件: diff --git "a/docs/Java\350\265\236\345\212\251\347\211\210\344\270\213\350\275\275\351\223\276\346\216\245\350\257\264\346\230\216.md" "b/docs/Java\350\265\236\345\212\251\347\211\210\344\270\213\350\275\275\351\223\276\346\216\245\350\257\264\346\230\216.md" index 577995ef..d912be1d 100644 --- "a/docs/Java\350\265\236\345\212\251\347\211\210\344\270\213\350\275\275\351\223\276\346\216\245\350\257\264\346\230\216.md" +++ "b/docs/Java\350\265\236\345\212\251\347\211\210\344\270\213\350\275\275\351\223\276\346\216\245\350\257\264\346\230\216.md" @@ -1,76 +1,26 @@ -# Java环境下载链接与赞助者下载通道说明 +# Java赞助版下载链接说明 -## 1. 下载链接 +环境管理页面的 Java 8、11、17、21、25 下载地址已切换为国内高速短链,Windows、Linux x64 和 Linux ARM64 会根据服务端系统信息自动选择对应链接。 -环境管理 → Java 环境的下载地址已统一替换为 `https://download.xiaozhuhouses.asia/d/<文件ID>/<文件名>` 直链,按系统平台与架构自动选择(Java 8 / 11 / 17 / 21 / 25 / 27): +服务端会按照 Java 版本、操作系统和架构保留压缩包文件名。短链末尾不是压缩包扩展名,安装流程会使用对应的 `.zip` 或 `.tar.gz` 文件名进行解压。 -| 版本 | Windows x64 | Linux x64 | Linux ARM64 | -| --- | --- | --- | --- | -| Java 8 | `/d/e2fb4833ba415b8c500aed2d9b88d401/openjdk-8u44-windows-i586.zip` | `/d/eba19d9b0eb2f5ee0af4aa1410e7e8ea/openjdk-8u44-linux-x64.tar.gz` | 未提供 | -| Java 11 | `/d/09844c5699181cace0c50838a01b3afa/openjdk-11.0.0.2_windows-x64.zip` | `/d/1ad2d19275d387bf474186213159971b/openjdk-11.0.0.2_linux-x64.tar.gz` | 未提供 | -| Java 17 | `/d/7dc046a7855530363fac794781bdf767/openjdk-17.0.0.1+2_windows-x64_bin.zip` | `/d/25b171ae761d3222bd0f91ddca373d32/openjdk-17.0.0.1+2_linux-x64_bin.tar.gz` | `/d/8868121de8e1a36192abfe4034ec3a7b/openjdk-17.0.2_linux-aarch64_bin.tar.gz` | -| Java 21 | `/d/a436ba580cf68550b0a14408105eb8d5/openjdk-21+35_windows-x64_bin.zip` | `/d/521a8c9551a8cd0923825789e3be5054/openjdk-21+35_linux-x64_bin.tar.gz` | `/d/4c5d060a6186d630f4c0cb64cac7c075/openjdk-21_linux-aarch64_bin.tar.gz` | -| Java 25 | `/d/1e1b2424fef706eb9f13851ad9081aff/openjdk-25+36_windows-x64_bin.zip` | `/d/59e59a84f693481bb7a29802a2ef5253/openjdk-25+36_linux-x64_bin.tar.gz` | `/d/a6f7a6c56d31bcb9c47927fb55386343/openjdk-25.0.2_linux-aarch64_bin.tar.gz` | -| Java 27 | `/d/b1b2ce98fd714e8202a6d0cd8893237e/openjdk-27+35_windows-x64_bin.zip` | `/d/57849c8d615de1b355d88ba2fa2d6bf2/openjdk-27+35_linux-x64_bin.tar.gz` | 未提供 | +下载式 Java 安装只适用于已有明确压缩包产物的架构。RISC-V64、macOS 和未列出的 Linux 架构不会回退到 Linux x64 包,前端会禁用安装按钮,服务端也会拒绝安装请求并提示改用系统 OpenJDK。 -表内链接需补上域名前缀 `https://download.xiaozhuhouses.asia`。 +RISC-V64 等特殊架构推荐使用发行版包管理器安装 Java,例如: -- 链接配置位置:`client/src/pages/EnvironmentManagerPage.tsx` 中的 `javaVersions`。 -- 旧的 `https://download.xiaozhuhouses.asia/download/v1/links/...` 短链已全部替换。 -- **ARM64 缺口**:上游只提供了 Java 17 / 21 / 25 的 ARM64 安装包,Java 8、Java 11、Java 27 没有 ARM64 链接,因此在 ARM64 系统上这几项会显示「该版本暂未提供当前架构的安装包」并禁用安装按钮,避免误下 x64 包导致安装后无法运行。 -- 压缩包文件名映射保留在 `server/src/routes/environment.ts` 的 `getJavaArchiveFileName()`,Java 11 ARM64 条目已删除;未配置的平台会退化为按下载地址末段推断文件名,不再直接抛错。 +```bash +sudo apt-get update +sudo apt-get install -y default-jre-headless +# 或按发行版可用版本安装 openjdk-17-jre-headless / openjdk-21-jre-headless +``` -## 2. 赞助者下载通道 +实例未指定面板内 Java 版本时,会使用系统 `PATH` 中的 `java`。因此系统 OpenJDK 安装完成后,可以在实例中保持 Java 版本为空,或让启动命令直接使用 `java -jar ...`。 -`POST /api/environment/java/install` 会先读取本地记录的赞助者密钥(`server/src/utils/sponsorStatus.ts` 的 `getSponsorKey()`): +本次更新的链接配置位于: -- **未记录密钥**:使用普通通道,直接请求上表直链,由公网节点(`r1.files.xiaozhuhouses.asia`)提供下载。 -- **已记录密钥**:使用赞助者通道,流程与官方赞助者下载脚本一致。 +- `client/src/pages/EnvironmentManagerPage.tsx`:页面 Java 环境下载配置。 +- `server/src/routes/environment.ts`:赞助者专用下载地址和压缩包文件名映射。 +- `server/src/utils/javaDownloadArtifacts.ts`:下载式 Java 支持架构、赞助链接和压缩包文件名映射。 +- `server/src/modules/environment/javaManager.ts`:使用映射后的文件名保存并解压下载文件。 - 1. `POST https://download.xiaozhuhouses.asia/api/v1/download-auth/session`,请求体 `{ "activation_code": "<本地记录的密钥>" }`; - 成功返回 `HTTP 200` 与 `{"status":"ok","audience":"sponsor","expires_at":"...","sponsor_expires_at":"..."}`,并下发 `__Host-dl_session` 会话 Cookie。 - 2. 带上会话 Cookie 请求**同一个文件直链**,下载服务返回 307 跳转到赞助者节点(`files.cn-nb1.rains3.com` 预签名地址),随后正常下载。 - -说明: - -- 赞助者通道与普通通道使用同一个文件直链,区别只在于是否携带会话 Cookie,因此不需要单独维护一份赞助者链接表。 -- 会话 Cookie 仅在目标地址域名等于 `download.xiaozhuhouses.asia` 时携带(`isSponsorDownloadUrl()` 校验),避免把会话泄露到第三方地址。 -- 会话有效期 1 小时,每次安装都会重新建立会话。 -- 官方脚本中的 `"$base/"`(站点根路径)不能直接照抄:根路径现在会跳转到后台管理页,必须使用具体文件直链。 -- **失败自动降级**:密钥未记录、密钥无效(`503` + `赞助码不正确或服务不可用`)、会话服务不可用,或该版本在当前平台没有链接时,都会回退普通通道,不会中断安装。 -- 实测上游会话接口偶尔会返回 `503`(同一客户端已有大文件下载进行中时更容易出现)。面板固定在开始下载前建立会话,正常安装流程不受影响;如果仍失败,只会表现为这一次按普通通道下载。 - -### 代码入口 - -| 位置 | 说明 | -| --- | --- | -| `server/src/utils/sponsorDownload.ts` | `createSponsorDownloadSession()` 建立会话、`isSponsorDownloadUrl()` 域名校验 | -| `server/src/utils/sponsorStatus.ts` | `getSponsorKey()` / `hasSponsorKey()` 读取本地密钥;`isSponsorUnlocked()` 仍表示「身份未确认」 | -| `server/src/modules/environment/javaManager.ts` | `JavaDownloadOptions` 支持传入会话 Cookie,`downloadFile()` / `installJava()` 透传 | -| `server/src/routes/environment.ts` | `POST /java/install` 组装下载通道并决定提示文案 | -| `client/src/pages/EnvironmentManagerPage.tsx` | 链接配置、架构判断、赞助者状态提示 | -| `client/src/utils/sponsor.ts` | `hasSponsorKey()` 前端判定入口 | - -## 3. 界面表现 - -- 已记录密钥:绿色提示「已记录赞助者密钥,Java环境将通过赞助者专用通道下载(密钥无效时自动回退普通通道)」。 -- 未记录密钥:黄色提示 + 爱发电赞助入口。 -- 安装开始与安装完成的消息会在走赞助者通道时带上「(赞助者专用通道)」后缀;安装日志中也会输出 `(赞助者专用通道)`。 - -## 4. 验证结论 - -以 Java 17 Windows x64 链接为例(`/d/7dc046a7855530363fac794781bdf767/openjdk-17.0.0.1+2_windows-x64_bin.zip`): - -| 场景 | 结果 | -| --- | --- | -| 普通通道(不带 Cookie) | 307 → `r1.files.xiaozhuhouses.asia`,`200 application/zip`,`Content-Length: 186223043` | -| 赞助者通道(带会话 Cookie) | 307 → `files.cn-nb1.rains3.com` 预签名地址,`206 application/zip`,文件头 `504b`(PK,ZIP 包) | -| 无效激活码 | `503` + `{"error_code":"sponsor_unavailable","message":"赞助码不正确或服务不可用"}` → 服务端抛出中文错误并回退普通通道 | -| 面板自身下载方法(`JavaManager.downloadFile`) | 普通通道与赞助者通道均能落盘真实字节、进度回调正常;同一时间片内赞助者通道约 12MB、普通通道约 4MB | - -验证方式:临时脚本调用编译产物中的 `createSponsorDownloadSession()` / `isSponsorDownloadUrl()` 完成上述请求,验证后已删除。 - -## 5. 相关说明 - -- Node.js ARM64 下载链接不属于 Java 环境管理页面,本次未纳入。 -- 需要重新编译:`cd server && npm run build`、`cd client && npm run build`;若面板以打包产物运行,需要重启服务。 \ No newline at end of file +Node.js ARM64 下载链接不属于 Java 环境管理页面,本次未纳入 Java 下载配置。 diff --git "a/docs/Zip-Tools\351\233\206\346\210\220\350\257\264\346\230\216.md" "b/docs/Zip-Tools\351\233\206\346\210\220\350\257\264\346\230\216.md" index ce016f26..e2b03961 100644 --- "a/docs/Zip-Tools\351\233\206\346\210\220\350\257\264\346\230\216.md" +++ "b/docs/Zip-Tools\351\233\206\346\210\220\350\257\264\346\230\216.md" @@ -10,6 +10,7 @@ - **已存在**:跳过下载,记录日志 `Zip-Tools 已存在,跳过下载` - **不存在**:自动从 GitHub Releases 下载对应平台的二进制文件 +- **暂无内置资产的平台**:尝试使用系统 `unzip`/`zip` 降级 - **下载失败**:记录警告日志但不阻塞服务启动,ZIP 相关功能可能不可用 ### 下载地址 @@ -22,6 +23,8 @@ 打包脚本(`scripts/package.js`)和 Docker 构建(`Dockerfile`)在构建时直接从 GitHub Releases 下载 Zip-Tools 并内置到产物中,确保用户部署后无需额外下载。 +`linux-riscv64` 暂无内置 Zip-Tools 资产,打包时会跳过该二进制。运行时 ZIP 解压会尝试系统 `unzip`,ZIP 压缩会尝试系统 `zip`;系统工具缺失时,对应任务会失败并提示安装。 + ## 支持的平台和架构 | 操作系统 | CPU 架构 | 二进制文件名 | @@ -30,6 +33,7 @@ | Windows | ARM64 | `file_zip_win32_arm64.exe` | | Linux | x64 | `file_zip_linux_x64` | | Linux | ARM64 | `file_zip_linux_arm64` | +| Linux | RISC-V64 | 暂无内置资产,使用系统 `unzip`/`zip` 降级 | | macOS | x64 | `file_zip_darwin_amd64` | | macOS | ARM64 | `file_zip_darwin_arm64` | diff --git "a/docs/\345\244\232\346\236\266\346\236\204\346\224\257\346\214\201\350\257\264\346\230\216.md" "b/docs/\345\244\232\346\236\266\346\236\204\346\224\257\346\214\201\350\257\264\346\230\216.md" index d5c239ae..5f4a523f 100644 --- "a/docs/\345\244\232\346\236\266\346\236\204\346\224\257\346\214\201\350\257\264\346\230\216.md" +++ "b/docs/\345\244\232\346\236\266\346\236\204\346\224\257\346\214\201\350\257\264\346\230\216.md" @@ -2,204 +2,136 @@ ## 概述 -GSM3 游戏服务端管理面板现已支持多架构部署,包括 AMD64 (x86_64) 和 ARM64 (aarch64) 平台。不同架构提供不同级别的功能支持。 +GSM3 游戏服务端管理面板支持在多种 Linux 架构上部署。AMD64 仍是完整功能目标;ARM64 和 RISC-V64 更适合作为轻量 Web 管理面板或远程管理节点。不同架构的原生依赖、SteamCMD 和游戏服务端可用性并不相同,部署前应按下表确认。 ## 架构对比 -| 功能 | AMD64 | ARM64 | 说明 | -|------|-------|-------|------| -| GSM3 Web管理界面 | ✅ | ✅ | 完整支持 | -| Java 21 运行环境 | ✅ | ✅ | 完整支持 | -| Node.js 22 | ✅ | ✅ | 完整支持 | -| Python 3 | ✅ | ✅ | 完整支持 | -| SteamCMD | ✅ | ❌ | ARM64不支持 | -| 游戏服务器依赖 | ✅ | ❌ | 主要为x86游戏 | -| i386兼容库 | ✅ | ❌ | ARM64无需 | - -## 使用场景 - -### AMD64 架构 -- **完整游戏服务器管理** -- 适合传统服务器环境 -- 支持所有Steam游戏服务器 -- 资源消耗较高 - -### ARM64 架构 -- **轻量级管理面板** -- 适合树莓派、ARM服务器 -- 仅Web管理功能 -- 资源消耗较低 -- 适合远程管理场景 - -## 快速开始 - -### 1. 检查系统架构 +| 功能 | AMD64 (x86_64) | ARM64 (aarch64) | RISC-V64 (riscv64) | 说明 | +|------|----------------|-----------------|--------------------|------| +| GSM3 Web 管理界面 | 支持 | 支持 | 支持 | RISC-V64 需要系统预装 Node.js >= 18 | +| 发布安装包 | `linux-x64` | `linux-arm64` | `linux-riscv64` | RISC-V64 包不内置 Node.js | +| 内置 Node.js 22 | 支持 | 支持 | 不支持 | Node.js 官方 v22.17.0 未提供该项目当前使用的 linux-riscv64 预编译包 | +| 固定 PTY 资产 | 支持 | 支持 | 暂无 | RISC-V64 终端功能在缺少上游 PTY 资产时降级为不可用 | +| Zip-Tools / 7z 内置资产 | 支持 | 支持 | 暂无 | RISC-V64 运行时会尝试使用系统 `unzip`、`zip`、`7z`/`7zz` | +| 下载式 Java 安装 | 支持 | 部分支持 | 暂无 | RISC-V64 不会回退下载 x64 JDK;推荐使用系统 OpenJDK | +| SteamCMD | 支持 | 不支持 | 不支持 | SteamCMD 官方主要面向 x86 Linux,非 x64 架构会隐藏入口并由后端拒绝 | +| Factorio 自动部署 | 支持 | 不支持 | 不支持 | 官方 headless 下载为 `linux64` 构建 | +| 游戏服务器依赖 | 支持 | 取决于游戏 | 取决于游戏 | 大多数商用游戏服务端只发布 x86_64 构建 | +| Docker 镜像 | 支持 | 支持 | 暂不声明支持 | Dockerfile 仍只声明 AMD64/ARM64 | + +## 快速检查 ```bash -# 查看系统架构 uname -m - -# x86_64 = AMD64 -# aarch64 = ARM64 +node -v +command -v unzip zip 7z 7zz ``` -### 2. 选择部署方式 +架构映射: -#### 方式一:使用构建脚本(推荐) +- `x86_64` / `amd64` -> `linux-x64` +- `aarch64` / `arm64` -> `linux-arm64` +- `riscv64` -> `linux-riscv64` -```bash -# 本地构建(自动检测架构) -./build-local.sh +## 构建安装包 -# 多架构构建 -./build-multiarch.sh -``` +```bash +# AMD64 +npm run package:linux:x64 -#### 方式二:Docker Compose +# ARM64 +npm run package:linux:arm64 -```bash -# 自动选择合适的架构 -docker-compose up -d +# RISC-V64 +npm run package:linux:riscv64 ``` -#### 方式三:直接运行 +仅生成目录、不创建 zip: ```bash -# AMD64 - 完整功能 -docker run -d \ - --name gsm3-panel \ - -p 3001:3001 \ - -p 27015-27020:27015-27020 \ - -v ./game_data:/root/games \ - -v ./gsm3_data:/root/server/data \ - xiaozhu674/gameservermanager:latest - -# ARM64 - 仅管理面板 -docker run -d \ - --name gsm3-panel \ - -p 3001:3001 \ - -v ./gsm3_data:/root/server/data \ - xiaozhu674/gameservermanager:latest +npm run package:linux:riscv64:no-zip ``` -## 构建说明 - -### 本地构建 +RISC-V64 包内已经包含服务端生产依赖,但不会包含 Node.js、PTY、Zip-Tools 或 7z 原生资产。目标机器需要预先安装: ```bash -# 给脚本执行权限 -chmod +x build-local.sh - -# 构建当前架构版本 -./build-local.sh latest +sudo apt-get update +sudo apt-get install -y nodejs npm unzip zip p7zip-full default-jre-headless ``` -### 多架构构建 +不同发行版的软件包名称可能不同;最低要求是 `node` 版本 >= 18。若只需要启动 Web 管理面板,`zip`、`p7zip-full` 可稍后按需安装。 -```bash -# 给脚本执行权限 -chmod +x build-multiarch.sh +## 部署方式建议 -# 构建多架构版本 -./build-multiarch.sh latest -``` +### AMD64 -## 功能限制说明 +- 适合作为完整游戏服务器管理节点。 +- 可使用 Docker 或发布安装包。 +- SteamCMD、常见游戏服务端和 i386 兼容库支持最完整。 -### ARM64 架构限制 +### ARM64 -1. **SteamCMD 不可用** - - SteamCMD 官方不支持 ARM64 - - 无法直接管理 Steam 游戏服务器 +- 适合作为轻量 Web 管理面板、远程监控和配置节点。 +- 发布安装包内置 Node.js 与当前 ARM64 原生资产。 +- SteamCMD 和大多数 x86 游戏服务端不可用。 -2. **游戏服务器依赖缺失** - - 大部分游戏服务器为 x86 架构 - - 缺少 i386 兼容库 +### RISC-V64 -3. **推荐用途** - - Web 管理界面访问 - - 远程监控和配置 - - 轻量级部署场景 +- 适合作为 Web 管理面板或架构适配测试节点。 +- 推荐使用发布安装包或本地源码运行,不建议把 Docker 视为已支持路径。 +- 终端 PTY 依赖上游提供 riscv64 资产;在资产缺失时,服务可启动,但终端创建功能会不可用。 +- ZIP 解压可使用系统 `unzip` 降级;ZIP 压缩需要系统 `zip`;7z 功能需要系统 `7z` 或 `7zz`。 +- Java 下载式安装没有 RISC-V64 产物时会被禁用;如需运行 Minecraft 等 Java 服务端,请安装系统 OpenJDK,并在实例中使用系统 `java`。 -### 解决方案 +## 功能限制 -如果需要在 ARM 环境中管理游戏服务器,建议: +1. **SteamCMD 限制** + - 非 x86_64 架构不要默认承诺 SteamCMD 可用。 + - SteamCMD、实例市场和在线部署入口在非 x64 架构上会被前端隐藏;后端也会拒绝 SteamCMD 分支查询、安装和更新请求。 + - 即使面板可运行,游戏服务端是否能运行仍取决于游戏厂商是否提供对应架构构建。 + - Factorio 自动部署使用官方 `linux64` headless 包,非 Linux x86_64 架构请改用手动文件部署。 -1. **混合部署** - - ARM64: 运行 GSM3 管理面板 - - AMD64: 运行游戏服务器 +2. **原生资产限制** + - 新增架构前,需要逐项确认 Node.js、PTY、Zip-Tools、7z、Java 和游戏运行时。 + - 没有上游资产时,应明确降级或禁用对应功能,而不是下载错误架构的二进制。 -2. **远程管理** - - ARM64 设备作为管理终端 - - 通过网络管理远程 AMD64 服务器 +3. **RISC-V64 前置条件** + - 需要系统 Node.js >= 18。 + - 如需压缩/解压功能,安装 `unzip`、`zip`、`p7zip-full` 或发行版等价包。 + - 如需 Java 游戏服务端,安装 `default-jre-headless`、`openjdk-17-jre-headless`、`openjdk-21-jre-headless` 或发行版可用的等价包。 + - PTY 终端功能等待上游 riscv64 资产后才能完整支持。 ## 故障排除 -### 架构不匹配 +### Node.js 缺失 ```bash -# 检查镜像架构 -docker image inspect xiaozhu674/gameservermanager:latest | grep Architecture - -# 强制拉取特定架构 -docker pull --platform linux/amd64 xiaozhu674/gameservermanager:latest -docker pull --platform linux/arm64 xiaozhu674/gameservermanager:latest +node -v ``` -### 构建失败 - -```bash -# 检查 Docker Buildx -docker buildx version - -# 重新创建构建器 -docker buildx create --name multiarch-builder --driver docker-container --bootstrap -docker buildx use multiarch-builder -``` +如果命令不存在或版本低于 18,请先安装发行版提供的 Node.js,或使用该发行版推荐的 Node.js 安装方式。 -### 运行时错误 +### ZIP/7z 功能不可用 ```bash -# 查看容器日志 -docker logs gsm3-panel - -# 进入容器调试 -docker exec -it gsm3-panel bash - -# 检查架构 -docker exec gsm3-panel uname -m +command -v unzip +command -v zip +command -v 7z || command -v 7zz ``` -## 性能对比 +RISC-V64 上没有内置 Zip-Tools/7z 资产,系统命令不存在时,对应压缩或解压任务会失败并给出安装提示。 -| 指标 | AMD64 | ARM64 | 说明 | -|------|-------|-------|------| -| 内存使用 | ~1.5GB | ~800MB | ARM64更节省 | -| CPU使用 | 中等 | 较低 | 无游戏服务器负载 | -| 启动时间 | ~30s | ~20s | ARM64更快 | -| 镜像大小 | ~2GB | ~1.2GB | ARM64更小 | +### Docker 架构不匹配 -## 最佳实践 - -1. **选择合适的架构** - - 游戏服务器: AMD64 - - 管理面板: ARM64 - -2. **资源配置** - - AMD64: 2GB+ 内存, 2+ CPU - - ARM64: 1GB+ 内存, 1+ CPU - -3. **网络配置** - - AMD64: 开放游戏端口 - - ARM64: 仅开放管理端口 (3001) +```bash +docker image inspect xiaozhu674/gameservermanager:latest | grep Architecture +docker pull --platform linux/amd64 xiaozhu674/gameservermanager:latest +docker pull --platform linux/arm64 xiaozhu674/gameservermanager:latest +``` -4. **存储配置** - - AMD64: 大容量存储 - - ARM64: 轻量级存储 +当前 Docker 镜像只声明 AMD64 和 ARM64。不要在发布说明中把 RISC-V64 Docker 作为已支持能力。 ## 更新日志 -- **v1.0.0**: 添加 ARM64 架构支持 -- 优化镜像大小和构建时间 -- 提供多架构构建脚本 -- 完善文档和使用说明 +- **v1.1.0**: 增加 `linux-riscv64` 发布安装包,采用系统 Node.js 和系统压缩工具降级。 +- **v1.0.0**: 添加 ARM64 架构支持。 diff --git "a/docs/\347\246\273\347\272\277\345\256\211\350\243\205\344\276\235\350\265\226\350\257\264\346\230\216.md" "b/docs/\347\246\273\347\272\277\345\256\211\350\243\205\344\276\235\350\265\226\350\257\264\346\230\216.md" index ae335d78..49e31c72 100644 --- "a/docs/\347\246\273\347\272\277\345\256\211\350\243\205\344\276\235\350\265\226\350\257\264\346\230\216.md" +++ "b/docs/\347\246\273\347\272\277\345\256\211\350\243\205\344\276\235\350\265\226\350\257\264\346\230\216.md" @@ -4,15 +4,18 @@ 通过 `npm run package:windows`、`npm run package:linux:x64` 或 `npm run package:linux:arm64` 生成的目标平台安装包,在完整解压后不需要执行 `npm install`,也不需要为 PTY、Zip-Tools 或 7z 联网下载二进制。 +`npm run package:linux:riscv64` 生成的是降级便携包:包内包含服务端生产依赖,但不内置 Node.js、PTY、Zip-Tools 或 7z 原生资产。目标机器需要系统预装 Node.js >= 18;ZIP/7z 功能按需依赖系统 `unzip`、`zip`、`7z`/`7zz`。 + ## 打包行为 打包脚本会在构建阶段完成以下操作: 1. 在安装包的 `server/node_modules` 安装服务端生产依赖。 -2. 将目标平台的 Node.js 放入安装包。 -3. 优先复用 `server/data/lib` 中已存在的 PTY、Zip-Tools 和 7z 文件;本地没有时仅允许由构建机下载。 +2. 对 Windows x64、Linux x64 和 Linux ARM64,将目标平台的 Node.js 放入安装包。 +3. 对存在上游资产的目标平台,优先复用 `server/data/lib` 中已存在的 PTY、Zip-Tools 和 7z 文件;本地没有时仅允许由构建机下载。 4. 使用固定清单校验 PTY 的文件名、大小、SHA-256 和本机能力。 -5. 如果任何目标平台运行时二进制最终无法放入 `data/lib`,打包命令会失败,不会输出不完整的安装包。 +5. 如果完整支持目标平台的运行时二进制最终无法放入 `data/lib`,打包命令会失败,不会输出不完整的安装包。 +6. 对 RISC-V64 这类暂无上游原生资产的目标平台,打包脚本会明确跳过对应资产,并在运行时使用系统工具降级。 `server/data/lib` 是构建缓存目录,默认不纳入 Git。需要在完全离线的构建机上打包时,必须预先向其中放入目标平台所需的二进制文件。 @@ -24,7 +27,7 @@ ## 验收检查 -解压后应至少存在以下路径: +完整支持目标平台解压后应至少存在以下路径: - `server/node_modules` - `data/lib/pty_<目标平台和架构>` @@ -32,3 +35,11 @@ - `data/lib/7z_<目标平台和架构>` Windows x64 对应的文件名为 `pty_win32_x64.exe`、`file_zip_win32_x64.exe` 和 `7z_win32_x64.exe`。 + +RISC-V64 包不要求存在上述 `data/lib` 原生二进制,但必须满足: + +- `server/node_modules` 存在 +- 目标系统 `node -v` 版本 >= 18 +- 如需 ZIP 解压,`unzip` 存在 +- 如需 ZIP 压缩,`zip` 存在 +- 如需 7z 功能,`7z` 或 `7zz` 存在 diff --git a/install-gsm3.sh b/install-gsm3.sh index 71a56152..2c48c64e 100755 --- a/install-gsm3.sh +++ b/install-gsm3.sh @@ -196,6 +196,10 @@ if test "$install_type" = "1"; then PACKAGE_TARGET="linux-arm64" PTY_ASSET="linux-arm64" ;; + riscv64) + PACKAGE_TARGET="linux-riscv64" + PTY_ASSET="" + ;; *) echo -e "\x1b[31m不支持的系统架构: $ARCH\x1b[0m" exit 1 @@ -226,18 +230,41 @@ if test "$install_type" = "1"; then exit 1 fi rm -rf gsm3.tgz - chmod 755 "$install_path/node/bin/node" "$install_path/start.sh" 2>/dev/null || true + chmod 755 "$install_path/start.sh" 2>/dev/null || true + if [ -x "$install_path/node/bin/node" ]; then + NODE_BIN="$install_path/node/bin/node" + chmod 755 "$NODE_BIN" 2>/dev/null || true + else + NODE_BIN="$(command -v node || true)" + if [ -z "$NODE_BIN" ]; then + echo -e "\x1b[31m当前安装包需要系统预装 Node.js >= 18,请先安装 nodejs 后重试。\x1b[0m" + echo -e "\x1b[33mDebian/Ubuntu 可参考: apt-get update && apt-get install -y nodejs npm\x1b[0m" + exit 1 + fi + NODE_MAJOR="$("$NODE_BIN" -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0)" + case "$NODE_MAJOR" in + ""|*[!0-9]*) NODE_MAJOR=0 ;; + esac + if [ "$NODE_MAJOR" -lt 18 ]; then + echo -e "\x1b[31mNode.js 版本过低,当前版本为 $("$NODE_BIN" -v 2>/dev/null || echo unknown),需要 >= 18。\x1b[0m" + exit 1 + fi + fi # 通过打包产物中的固定资产 CLI 校验或修复 PTY mkdir -p "$install_path/data/lib" - echo -e "\x1b[33m正在校验固定 PTY 资产...\x1b[0m" - if "$install_path/node/bin/node" \ - "$install_path/server/utils/ptyAssetCli.js" ensure \ - --asset "$PTY_ASSET" \ - --target-dir "$install_path/data/lib"; then - echo -e "\x1b[32mPTY 资产校验完成\x1b[0m" + if [ -n "$PTY_ASSET" ]; then + echo -e "\x1b[33m正在校验固定 PTY 资产...\x1b[0m" + if "$NODE_BIN" \ + "$install_path/server/utils/ptyAssetCli.js" ensure \ + --asset "$PTY_ASSET" \ + --target-dir "$install_path/data/lib"; then + echo -e "\x1b[32mPTY 资产校验完成\x1b[0m" + else + echo -e "\x1b[33mPTY 资产校验或下载失败;在运行时校验成功前,终端创建功能将保持不可用\x1b[0m" + fi else - echo -e "\x1b[33mPTY 资产校验或下载失败;在运行时校验成功前,终端创建功能将保持不可用\x1b[0m" + echo -e "\x1b[33m当前架构暂无固定 PTY 资产;终端创建功能将保持降级,其它 Web 管理功能可继续使用\x1b[0m" fi # 设置其他lib文件权限 @@ -256,7 +283,7 @@ After=network.target [Service] Type=simple WorkingDirectory=$install_path -ExecStart=$install_path/node/bin/node server/index.js +ExecStart=$NODE_BIN server/index.js Restart=on-failure [Install] diff --git a/package.json b/package.json index e10b03ec..aadc438a 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,13 @@ "package:linux": "npm run package:linux:x64", "package:linux:x64": "npm run build && node scripts/package.js --target=linux-x64", "package:linux:arm64": "npm run build && node scripts/package.js --target=linux-arm64", + "package:linux:riscv64": "npm run build && node scripts/package.js --target=linux-riscv64", "package:windows": "npm run build && node scripts/package.js --target=windows", "package:no-zip": "npm run build && node scripts/package.js --no-zip", "package:linux:no-zip": "npm run package:linux:x64:no-zip", "package:linux:x64:no-zip": "npm run build && node scripts/package.js --target=linux-x64 --no-zip", "package:linux:arm64:no-zip": "npm run build && node scripts/package.js --target=linux-arm64 --no-zip", + "package:linux:riscv64:no-zip": "npm run build && node scripts/package.js --target=linux-riscv64 --no-zip", "package:windows:no-zip": "npm run build && node scripts/package.js --target=windows --no-zip", "start": "cd server && npm start", "start:prod": "cd dist && node index.js", diff --git a/scripts/package.js b/scripts/package.js index 32bfb63a..94978af1 100644 --- a/scripts/package.js +++ b/scripts/package.js @@ -18,14 +18,21 @@ const packageDir = path.join(distDir, 'package') const args = process.argv.slice(2) const requestedBuildTarget = args.find(arg => arg.startsWith('--target='))?.split('=')[1] const skipZip = args.includes('--no-zip') || args.includes('--skip-zip') +const supportedBuildTargets = new Set(['windows', 'linux-x64', 'linux-arm64', 'linux-riscv64']) +const linuxBuildTargets = new Set(['linux-x64', 'linux-arm64', 'linux-riscv64']) + +function targetBundlesNode(target) { + return target === 'windows' || target === 'linux-x64' || target === 'linux-arm64' +} function resolveBuildTarget(target) { - if (!target || target === 'windows' || target === 'linux-x64' || target === 'linux-arm64') { + if (!target || supportedBuildTargets.has(target)) { return target } if (target === 'linux') { if (process.arch === 'x64') return 'linux-x64' if (process.arch === 'arm64') return 'linux-arm64' + if (process.arch === 'riscv64') return 'linux-riscv64' throw new Error(`不支持当前架构的 Linux 打包: ${process.arch}`) } throw new Error(`不支持的打包目标: ${target}`) @@ -85,6 +92,7 @@ const ZIP_TOOLS_GITHUB_URL = 'https://github.com/MCSManager/Zip-Tools/releases/l function getZipToolsBinaries(target) { if (target === 'linux-x64') return ['file_zip_linux_x64'] if (target === 'linux-arm64') return ['file_zip_linux_arm64'] + if (target === 'linux-riscv64') return [] if (target === 'windows') return ['file_zip_win32_x64.exe'] // 未指定目标时下载所有版本 return [ @@ -102,6 +110,7 @@ function getZipToolsBinaries(target) { function get7zBinaries(target) { if (target === 'linux-x64') return ['7z_linux_x64'] if (target === 'linux-arm64') return ['7z_linux_arm64'] + if (target === 'linux-riscv64') return [] if (target === 'windows') return ['7z_win32_x64.exe'] // 未指定目标时下载所有版本 return [ @@ -183,6 +192,11 @@ async function downloadZipTools(platform) { const libDir = path.join(packageDir, 'data', 'lib') await fs.ensureDir(libDir) + if (binaries.length === 0) { + console.log('ℹ️ 当前目标没有可内置的 Zip-Tools 资产,运行时将尝试使用系统 unzip/zip') + return + } + console.log('📥 正在准备 Zip-Tools 运行时资产...') let hasSuccess = false @@ -223,6 +237,11 @@ async function download7z(platform) { const libDir = path.join(packageDir, 'data', 'lib') await fs.ensureDir(libDir) + if (binaries.length === 0) { + console.log('ℹ️ 当前目标没有可内置的 7z 资产,运行时将尝试使用系统 7z/7zz') + return + } + console.log('📥 正在准备 7z 运行时资产...') let hasSuccess = false @@ -260,6 +279,7 @@ async function download7z(platform) { function getPtyAssetKeys(target) { if (target === 'linux-x64') return ['linux-x64'] if (target === 'linux-arm64') return ['linux-arm64'] + if (target === 'linux-riscv64') return [] if (target === 'windows') return ['win32-x64'] return ['linux-x64', 'linux-arm64', 'win32-x64'] } @@ -270,6 +290,12 @@ function getPtyAssetKeys(target) { async function ensurePtyAssets(target) { const libDir = path.join(packageDir, 'data', 'lib') await fs.ensureDir(libDir) + const assetKeys = getPtyAssetKeys(target) + + if (assetKeys.length === 0) { + console.log('ℹ️ 当前目标没有固定 PTY 资产,跳过 PTY 预置;终端功能将在运行时降级') + return + } // PTY 资产的完整性和本机能力由 ptyAssetCli 统一校验。 // 此处只预复制已缓存的候选文件,避免重复维护固定资产清单。 @@ -287,7 +313,7 @@ async function ensurePtyAssets(target) { } console.log('📥 正在校验固定 PTY 资产...') - for (const assetKey of getPtyAssetKeys(target)) { + for (const assetKey of assetKeys) { execFileSync(process.execPath, [ path.join(packageDir, 'server', 'utils', 'ptyAssetCli.js'), 'ensure', @@ -371,6 +397,58 @@ async function deployNodejs(target, downloadedFile) { await fs.remove(downloadedFile) } +function createLinuxStartScript(target) { + if (targetBundlesNode(target)) { + return `#!/bin/bash +set -e +echo "正在启动GSM3管理面板..." +if [ ! -x "node/bin/node" ]; then + echo "错误:未找到内置 Node.js,请确认安装包完整解压" + exit 1 +fi +# PTY 文件已迁移到 data/lib/ 目录,启动时由服务端自动检测 +node/bin/node server/index.js` + } + + return `#!/bin/bash +set -e +echo "正在启动GSM3管理面板..." +if ! command -v node >/dev/null 2>&1; then + echo "错误:当前安装包需要系统预装 Node.js >= 18" + exit 1 +fi +NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0) +case "$NODE_MAJOR" in + ''|*[!0-9]*) NODE_MAJOR=0 ;; +esac +if [ "$NODE_MAJOR" -lt 18 ]; then + echo "错误:Node.js 版本过低,当前版本为 $(node -v 2>/dev/null || echo unknown),需要 >= 18" + exit 1 +fi +# PTY 文件已迁移到 data/lib/ 目录,启动时由服务端自动检测;无固定资产的架构会降级 +node server/index.js` +} + +function getNodeInstallLine(target) { + if (targetBundlesNode(target)) { + return `本包已内置 Node.js ${nodeVersion},无需单独安装` + } + if (target === 'linux-riscv64') { + return '本包为 linux-riscv64 便携包,需要系统预装 Node.js (版本 >= 18)' + } + return '确保已安装 Node.js (版本 >= 18)' +} + +function getRuntimeNoteLine(target) { + if (targetBundlesNode(target)) { + return `本包已内置 Node.js ${nodeVersion} 和目标平台运行时资产` + } + if (target === 'linux-riscv64') { + return 'linux-riscv64 包已预装 Node.js 生产依赖,但需要系统 Node.js >= 18;PTY 暂无固定资产,ZIP/7z 功能会优先使用系统 unzip/zip/7z' + } + return 'Node.js依赖已预装' +} + async function createPackage() { try { console.log(`🚀 开始创建生产包${buildTarget ? ` (目标平台: ${buildTarget})` : ''}...`) @@ -469,14 +547,16 @@ async function createPackage() { ) // 根据目标平台下载和部署Node.js - if (buildTarget) { + if (buildTarget && targetBundlesNode(buildTarget)) { const downloadedNodeFile = await downloadNodejs(buildTarget) await deployNodejs(buildTarget, downloadedNodeFile) + } else if (buildTarget === 'linux-riscv64') { + console.log('ℹ️ linux-riscv64 暂无官方 Node.js 预编译包,本安装包将使用目标系统的 Node.js') } else { console.log('ℹ️ 未指定目标平台,跳过Node.js下载') } - // 运行时二进制缺失时必须让打包失败,不能生成会在用户机器联网补依赖的安装包。 + // 完整支持的目标平台必须内置运行时资产;暂无上游资产的目标平台在运行时使用系统工具降级。 await downloadZipTools(buildTarget) await download7z(buildTarget) @@ -488,11 +568,8 @@ async function createPackage() { path.join(__dirname, 'start.bat'), path.join(packageDir, 'start.bat') ) - } else if (buildTarget === 'linux-x64' || buildTarget === 'linux-arm64') { - const startShScript = `#!/bin/bash -echo "正在启动GSM3管理面板..." -# PTY 文件已迁移到 data/lib/ 目录,启动时由服务端自动检测 -node/bin/node server/index.js` + } else if (buildTarget && linuxBuildTargets.has(buildTarget)) { + const startShScript = createLinuxStartScript(buildTarget) await fs.writeFile( path.join(packageDir, 'start.sh'), @@ -542,7 +619,7 @@ node server/index.js` ## 安装说明 -1. ${buildTarget ? `本包已内置 Node.js ${nodeVersion},无需单独安装` : '确保已安装 Node.js (版本 >= 18)'} +1. ${getNodeInstallLine(buildTarget)} 2. 解压缩包到目标目录 3. (可选) 配置端口和其他参数: - 复制 .env.example 为 .env 并修改 SERVER_PORT 等配置 @@ -563,7 +640,7 @@ http://localhost:3001 ## 注意事项 -- ${buildTarget ? `本包已内置 Node.js ${nodeVersion} 和所有依赖` : 'Node.js依赖已预装'} +- ${getRuntimeNoteLine(buildTarget)} - 首次运行会自动创建默认管理员账户 (admin/admin123) - 请立即登录并修改默认密码 - 确保防火墙允许相关端口访问 diff --git a/server/src/__tests__/architecture-support.test.ts b/server/src/__tests__/architecture-support.test.ts new file mode 100644 index 00000000..566c45a3 --- /dev/null +++ b/server/src/__tests__/architecture-support.test.ts @@ -0,0 +1,33 @@ +import { + assertFactorioHeadlessSupported, + assertSteamCMDSupported, + isX64Architecture, + UnsupportedArchitectureError +} from '../utils/architectureSupport.js' + +describe('architecture support guards', () => { + it('recognizes common x64 architecture aliases', () => { + expect(isX64Architecture('x64')).toBe(true) + expect(isX64Architecture('x86_64')).toBe(true) + expect(isX64Architecture('amd64')).toBe(true) + expect(isX64Architecture('riscv64')).toBe(false) + expect(isX64Architecture('arm64')).toBe(false) + }) + + it('allows SteamCMD on Windows and Linux x64', () => { + expect(() => assertSteamCMDSupported('win32', 'arm64')).not.toThrow() + expect(() => assertSteamCMDSupported('linux', 'x64')).not.toThrow() + expect(() => assertSteamCMDSupported('linux', 'amd64')).not.toThrow() + }) + + it('rejects SteamCMD on unsupported Linux architectures', () => { + expect(() => assertSteamCMDSupported('linux', 'riscv64')).toThrow(UnsupportedArchitectureError) + expect(() => assertSteamCMDSupported('linux', 'arm64')).toThrow(/SteamCMD/) + }) + + it('allows Factorio headless only on Linux x64', () => { + expect(() => assertFactorioHeadlessSupported('linux', 'x64')).not.toThrow() + expect(() => assertFactorioHeadlessSupported('linux', 'riscv64')).toThrow(UnsupportedArchitectureError) + expect(() => assertFactorioHeadlessSupported('win32', 'x64')).toThrow(/Factorio/) + }) +}) diff --git a/server/src/__tests__/java-download-artifacts.test.ts b/server/src/__tests__/java-download-artifacts.test.ts new file mode 100644 index 00000000..293663ba --- /dev/null +++ b/server/src/__tests__/java-download-artifacts.test.ts @@ -0,0 +1,114 @@ +import { + getJavaDownloadCatalog, + getJavaArchiveFileName, + getJavaDownloadPlatformKey, + getSponsorDownloadUrl, + resolveJavaDownloadOption, + UnsupportedJavaDownloadError +} from '../utils/javaDownloadArtifacts.js' + +describe('java download artifacts', () => { + it('maps Linux x64 to the x64 Java archive', () => { + expect(getJavaDownloadPlatformKey('linux', 'x64')).toBe('linux') + expect(getJavaArchiveFileName('java17', 'linux', 'x64')).toBe('openjdk-17.0.0.1+2_linux-x64_bin.tar.gz') + }) + + it('maps Linux arm64 to the arm Java archive', () => { + expect(getJavaDownloadPlatformKey('linux', 'arm64')).toBe('arm') + expect(getJavaArchiveFileName('java17', 'linux', 'arm64')).toBe('openjdk-17.0.2_linux-aarch64_bin.tar.gz') + }) + + it('keeps riscv64 separate from Linux x64 Java artifacts', () => { + expect(getJavaDownloadPlatformKey('linux', 'riscv64')).toBe('riscv64') + expect(() => getJavaArchiveFileName('java17', 'linux', 'riscv64')).toThrow(/系统包管理器安装 OpenJDK/) + expect(() => getSponsorDownloadUrl('java17', 'linux', 'riscv64')).toThrow(/系统包管理器安装 OpenJDK/) + }) + + it('rejects Java versions that have no artifact for the current architecture', () => { + expect(() => getJavaArchiveFileName('java8', 'linux', 'arm64')).toThrow(UnsupportedJavaDownloadError) + expect(() => getSponsorDownloadUrl('java8', 'linux', 'arm64')).toThrow(/不支持当前 Java 版本或平台\/架构组合/) + }) + + it('rejects unsupported operating systems instead of treating them as Linux', () => { + expect(() => getJavaArchiveFileName('java17', 'darwin', 'x64')).toThrow(UnsupportedJavaDownloadError) + }) + + it('builds Eclipse Temurin download options from the Adoptium API pattern', async () => { + const resolution = await resolveJavaDownloadOption('java17', 'adoptium', 'linux', 'x64') + + expect(resolution.downloadUrl).toBe( + 'https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse' + ) + expect(resolution.archiveFileName).toBe('temurin-17-ga-linux-x64.jdk.tar.gz') + }) + + it('supports Temurin riscv64 downloads only for versions with current GA builds', async () => { + const resolution = await resolveJavaDownloadOption('java21', 'adoptium', 'linux', 'riscv64') + + expect(resolution.downloadUrl).toBe( + 'https://api.adoptium.net/v3/binary/latest/21/ga/linux/riscv64/jdk/hotspot/normal/eclipse' + ) + expect(resolution.archiveFileName).toBe('temurin-21-ga-linux-riscv64.jdk.tar.gz') + await expect(resolveJavaDownloadOption('java11', 'adoptium', 'linux', 'riscv64')).rejects.toThrow(/Temurin 暂不提供/) + }) + + it('supports custom Early Access versions through provider parameters', async () => { + const resolution = await resolveJavaDownloadOption('java28-ea', 'adoptium', 'linux', 'x64', { + releaseChannel: 'ea' + }) + + expect(resolution.version).toBe('java28-ea') + expect(resolution.releaseChannel).toBe('ea') + expect(resolution.downloadUrl).toBe( + 'https://api.adoptium.net/v3/binary/latest/28/ea/linux/x64/jdk/hotspot/normal/eclipse' + ) + }) + + it('keeps sponsor downloads behind sponsor availability', async () => { + await expect(resolveJavaDownloadOption('java17', 'sponsor', 'linux', 'x64')).rejects.toThrow(/赞助高速源/) + + const resolution = await resolveJavaDownloadOption('java17', 'sponsor', 'linux', 'x64', { + sponsorAvailable: true + }) + + expect(resolution.downloadUrl).toBe(getSponsorDownloadUrl('java17', 'linux', 'x64')) + expect(resolution.archiveFileName).toBe(getJavaArchiveFileName('java17', 'linux', 'x64')) + }) + + it('keeps sponsor unavailable on riscv64 while offering Temurin and system packages where valid', () => { + const catalog = getJavaDownloadCatalog('linux', 'riscv64') + const java17Options = catalog.options.filter(option => option.version === 'java17') + const java11Options = catalog.options.filter(option => option.version === 'java11') + + expect(catalog.platformKey).toBe('riscv64') + expect(catalog.versions.find(version => version.id === 'java27')).toEqual(expect.objectContaining({ + major: 27 + })) + expect(catalog.versions.find(version => version.id === 'java28-ea')).toEqual(expect.objectContaining({ + defaultChannel: 'ea' + })) + expect(catalog.providers.find(provider => provider.id === 'azul')).toEqual(expect.objectContaining({ + supportsCustomVersion: true + })) + expect(catalog.custom).toEqual(expect.objectContaining({ + defaultMajor: 25, + defaultChannel: 'ga' + })) + expect(java17Options.find(option => option.provider === 'sponsor')).toEqual(expect.objectContaining({ + available: false + })) + expect(java17Options.find(option => option.provider === 'adoptium')).toEqual(expect.objectContaining({ + available: true, + downloadUrl: 'https://api.adoptium.net/v3/binary/latest/17/ga/linux/riscv64/jdk/hotspot/normal/eclipse' + })) + expect(java11Options.find(option => option.provider === 'adoptium')).toEqual(expect.objectContaining({ + available: false + })) + expect(java17Options.find(option => option.provider === 'system')).toEqual(expect.objectContaining({ + available: true, + recommended: true, + packageManager: 'apt', + packageName: 'openjdk-17-jre-headless' + })) + }) +}) diff --git a/server/src/modules/environment/javaManager.ts b/server/src/modules/environment/javaManager.ts index a2a38733..4c7a229e 100644 --- a/server/src/modules/environment/javaManager.ts +++ b/server/src/modules/environment/javaManager.ts @@ -6,6 +6,7 @@ import * as tar from 'tar' import logger from '../../utils/logger.js' import { createTarSecurityFilter } from '../../utils/tarSecurityFilter.js' import { zipToolsManager } from '../../utils/zipToolsManager.js' +import { getSupportedJavaVersions } from '../../utils/javaDownloadArtifacts.js' export interface JavaEnvironment { version: string @@ -94,10 +95,16 @@ export class JavaManager { await this.ensureInstallDir() const platform = os.platform() - const javaVersions = ['java8', 'java11', 'java17', 'java21', 'java25', 'java27'] + const javaVersions = new Set(getSupportedJavaVersions().map(version => version.id)) + const installedEntries = await fs.readdir(this.installDir, { withFileTypes: true }) + for (const entry of installedEntries) { + if (entry.isDirectory() && /^java\d+(?:-ea)?$/i.test(entry.name)) { + javaVersions.add(entry.name) + } + } const environments: JavaEnvironment[] = [] - for (const version of javaVersions) { + for (const version of Array.from(javaVersions)) { const versionDir = this.getVersionDir(version) const installed = await fs.pathExists(versionDir) diff --git a/server/src/modules/environment/packageManager.ts b/server/src/modules/environment/packageManager.ts index 1f45831b..dda28f24 100644 --- a/server/src/modules/environment/packageManager.ts +++ b/server/src/modules/environment/packageManager.ts @@ -137,13 +137,27 @@ export class LinuxPackageManager { 'fonts-wqy-zenhei': { description: '文泉驿正黑字体', category: 'ARK游戏' }, 'fonts-wqy-microhei': { description: '文泉驿微米黑字体', category: 'ARK游戏' }, 'libc6': { description: 'GNU C库', category: 'ARK游戏' }, - 'libc6:i386': { description: '32位GNU C库', category: 'ARK游戏' } + 'libc6:i386': { description: '32位GNU C库', category: 'ARK游戏' }, + 'default-jre-headless': { description: '发行版默认OpenJDK运行环境(无图形界面)', category: 'Java运行时' }, + 'openjdk-8-jre-headless': { description: 'OpenJDK 8运行环境(无图形界面)', category: 'Java运行时' }, + 'openjdk-11-jre-headless': { description: 'OpenJDK 11运行环境(无图形界面)', category: 'Java运行时' }, + 'openjdk-17-jre-headless': { description: 'OpenJDK 17运行环境(无图形界面)', category: 'Java运行时' }, + 'openjdk-21-jre-headless': { description: 'OpenJDK 21运行环境(无图形界面)', category: 'Java运行时' } } constructor() { this.initializePackageManagers() } + private supportsI386Packages(): boolean { + const arch = os.arch() + return arch === 'x64' || arch === 'ia32' + } + + private shouldSkipPackageForCurrentArchitecture(packageName: string): boolean { + return packageName.includes(':i386') && !this.supportsI386Packages() + } + /** * 初始化包管理器可用性检查 */ @@ -191,7 +205,7 @@ export class LinuxPackageManager { const packages: PackageInfo[] = [] // 首先检查是否需要启用32位架构 - const has32BitPackages = Object.keys(this.aptPackages).some(name => name.includes(':i386')) + const has32BitPackages = this.supportsI386Packages() && Object.keys(this.aptPackages).some(name => name.includes(':i386')) if (has32BitPackages) { try { await this.enable32BitArchitecture() @@ -201,6 +215,11 @@ export class LinuxPackageManager { } for (const [packageName, info] of Object.entries(this.aptPackages)) { + if (this.shouldSkipPackageForCurrentArchitecture(packageName)) { + logger.debug(`包 ${packageName} 不适用于当前架构 ${os.arch()},跳过`) + continue + } + const installed = await this.checkPackageInstalled('apt', packageName) const available = await this.checkPackageAvailable('apt', packageName) @@ -287,6 +306,11 @@ export class LinuxPackageManager { throw new Error(`包管理器 ${packageManagerName} 不可用`) } + const unsupportedPackages = packageNames.filter(name => this.shouldSkipPackageForCurrentArchitecture(name)) + if (unsupportedPackages.length > 0) { + throw new Error(`当前架构 ${os.arch()} 不支持安装以下 i386 包: ${unsupportedPackages.join(', ')}`) + } + // 检查是否有32位包需要安装 const has32BitPackages = packageNames.some(name => name.includes(':i386')) diff --git a/server/src/modules/game/othergame/factorio-deployer.ts b/server/src/modules/game/othergame/factorio-deployer.ts index ce19ae43..a865e01c 100644 --- a/server/src/modules/game/othergame/factorio-deployer.ts +++ b/server/src/modules/game/othergame/factorio-deployer.ts @@ -10,6 +10,7 @@ import * as zlib from 'zlib'; import { pipeline } from 'stream'; import { zipToolsManager } from '../../../utils/zipToolsManager.js'; import { createTarSecurityFilter } from '../../../utils/tarSecurityFilter.js'; +import { assertFactorioHeadlessSupported } from '../../../utils/architectureSupport.js'; // ==================== 接口定义 ==================== @@ -193,6 +194,8 @@ export class FactorioDeployer { let actualTempFilePath = initialTempFilePath; try { + assertFactorioHeadlessSupported(); + // 检查是否已取消 if (this.cancelled) { throw new Error('操作已取消'); diff --git a/server/src/modules/game/othergame/unified-functions.ts b/server/src/modules/game/othergame/unified-functions.ts index 90a13ddb..f7b155c5 100644 --- a/server/src/modules/game/othergame/unified-functions.ts +++ b/server/src/modules/game/othergame/unified-functions.ts @@ -16,6 +16,7 @@ import { FileManager } from './minecraft-server-api.js'; import { pipeline } from 'stream'; import { promisify } from 'util'; import { createTarSecurityFilter } from '../../../utils/tarSecurityFilter.js'; +import { assertFactorioHeadlessSupported } from '../../../utils/architectureSupport.js'; // 创建promisify版本的mkdtemp const mkdtempAsync = promisify(mkdtemp); @@ -1070,6 +1071,7 @@ export async function deployFactorioServer(options: FactorioDeployOptions): Prom const deployment = globalDeploymentManager.createDeployment('factorio', targetDirectory, onProgress, deploymentId); try { + assertFactorioHeadlessSupported(); (deployment.cancellationToken as CancellationTokenImpl).throwIfCancelled(); const downloadUrl = 'https://factorio.com/get-download/stable/headless/linux64'; diff --git a/server/src/modules/steamcmd/SteamCMDManager.ts b/server/src/modules/steamcmd/SteamCMDManager.ts index c17dba4c..939e4fe3 100644 --- a/server/src/modules/steamcmd/SteamCMDManager.ts +++ b/server/src/modules/steamcmd/SteamCMDManager.ts @@ -16,6 +16,7 @@ import { prepareSteamCMDLaunch, quoteSteamCMDConsoleArgument } from '../../utils/steamcmdRunScript.js' +import { assertSteamCMDSupported } from '../../utils/architectureSupport.js' const STEAM_BRANCH_QUERY_TIMEOUT_MS = 2 * 60 * 1000 @@ -164,6 +165,7 @@ export class SteamCMDManager { const { installPath, onProgress, onStatusChange } = options try { + assertSteamCMDSupported() onStatusChange?.('正在准备安装目录...') // 确保安装目录存在 diff --git a/server/src/routes/environment.ts b/server/src/routes/environment.ts index 0d3d4370..1d333676 100644 --- a/server/src/routes/environment.ts +++ b/server/src/routes/environment.ts @@ -6,7 +6,13 @@ import type { JavaDownloadOptions } from '../modules/environment/index.js' import { LinuxPackageManager } from '../modules/environment/packageManager.js' import { authenticateToken } from '../middleware/auth.js' import { ConfigManager } from '../modules/config/ConfigManager.js' -import { getSponsorKey } from '../utils/sponsorStatus.js' +import { + getJavaArchiveFileName, + getJavaDownloadCatalog, + resolveJavaDownloadOption, + UnsupportedJavaDownloadError +} from '../utils/javaDownloadArtifacts.js' +import { getSponsorKey, hasSponsorKey } from '../utils/sponsorStatus.js' import { createSponsorDownloadSession, isSponsorDownloadUrl } from '../utils/sponsorDownload.js' // 存储Socket.IO实例的变量 @@ -72,62 +78,32 @@ router.get('/java', authenticateToken, async (req, res) => { } }) -// 获取Java压缩包文件名,下载地址末段可能不便直接作为文件名使用 -function getJavaArchiveFileName(version: string, platform: string, arch?: string): string { - const fileNames = { - java8: { - windows: 'openjdk-8u44-windows-i586.zip', - linux: 'openjdk-8u44-linux-x64.tar.gz' - }, - java11: { - windows: 'openjdk-11.0.0.2_windows-x64.zip', - linux: 'openjdk-11.0.0.2_linux-x64.tar.gz' - }, - java17: { - windows: 'openjdk-17.0.0.1+2_windows-x64_bin.zip', - linux: 'openjdk-17.0.0.1+2_linux-x64_bin.tar.gz', - arm: 'openjdk-17.0.2_linux-aarch64_bin.tar.gz' - }, - java21: { - windows: 'openjdk-21+35_windows-x64_bin.zip', - linux: 'openjdk-21+35_linux-x64_bin.tar.gz', - arm: 'openjdk-21_linux-aarch64_bin.tar.gz' - }, - java25: { - windows: 'openjdk-25+36_windows-x64_bin.zip', - linux: 'openjdk-25+36_linux-x64_bin.tar.gz', - arm: 'openjdk-25.0.2_linux-aarch64_bin.tar.gz' - }, - java27: { - windows: 'openjdk-27+35_windows-x64_bin.zip', - linux: 'openjdk-27+35_linux-x64_bin.tar.gz' - } - } - - let platformKey: 'windows' | 'linux' | 'arm' - if (platform === 'win32') { - platformKey = 'windows' - } else if (arch === 'arm64' || arch === 'aarch64') { - platformKey = 'arm' - } else { - platformKey = 'linux' - } +// 获取Java下载源、版本和预设目录 +router.get('/java/catalog', authenticateToken, async (req, res) => { + try { + const catalog = getJavaDownloadCatalog(process.platform, os.arch(), { + sponsorAvailable: hasSponsorKey(configManager) + }) - const fileName = fileNames[version]?.[platformKey] - if (!fileName) { - // 该版本没有提供对应平台的安装包(例如 ARM64 下的 Java 8/11/27),交给下载地址末段推断 - logger.warn(`未配置Java压缩包文件名,将按下载地址推断: ${version}, ${platform}, ${arch}`) - return '' + res.json({ + success: true, + data: catalog + }) + } catch (error) { + logger.error('获取Java下载目录失败:', error) + res.status(500).json({ + success: false, + message: '获取Java下载目录失败' + }) } - - return fileName -} +}) // 安装Java环境 router.post('/java/install', authenticateToken, async (req, res) => { - const { version, downloadUrl, socketId } = req.body + const { version, provider, releaseChannel, downloadUrl, socketId } = req.body + let installVersion = typeof version === 'string' ? version : '' - if (!version || !downloadUrl) { + if (!version || (!provider && !downloadUrl)) { return res.status(400).json({ success: false, message: '缺少必要参数' @@ -135,35 +111,49 @@ router.post('/java/install', authenticateToken, async (req, res) => { } try { - // 已记录赞助者密钥时尝试走赞助者专用通道:先建立下载会话,再用会话 Cookie 下载同一个地址 - let downloadOptions: JavaDownloadOptions | undefined - let useSponsorChannel = false - const archiveFileName = getJavaArchiveFileName(version, process.platform, os.arch()) || undefined const sponsorKey = getSponsorKey(configManager) + let finalDownloadUrl = downloadUrl + let archiveFileName: string | undefined + let downloadOptions: JavaDownloadOptions | undefined + let providerLabel = '' + let isSponsorDownload = false - if (sponsorKey && isSponsorDownloadUrl(downloadUrl)) { - try { - const session = await createSponsorDownloadSession(sponsorKey) - downloadOptions = { cookie: session.cookie } - useSponsorChannel = true - logger.info(`检测到本地记录的赞助者密钥,本次下载使用赞助者专用通道: ${downloadUrl}`) - } catch (error) { - // 密钥无效或服务不可用时不影响安装,自动回退普通下载通道 - logger.warn(`赞助者专用通道不可用,回退普通下载通道: ${error instanceof Error ? error.message : '未知错误'}`) + if (provider) { + const resolution = await resolveJavaDownloadOption(version, provider, process.platform, os.arch(), { + sponsorAvailable: sponsorKey !== null, + releaseChannel + }) + + installVersion = resolution.version + finalDownloadUrl = resolution.downloadUrl + archiveFileName = resolution.archiveFileName + providerLabel = resolution.providerLabel + isSponsorDownload = resolution.provider === 'sponsor' + + if (isSponsorDownload && sponsorKey && isSponsorDownloadUrl(finalDownloadUrl)) { + try { + const session = await createSponsorDownloadSession(sponsorKey) + downloadOptions = { cookie: session.cookie } + logger.info(`检测到本地记录的赞助者密钥,本次下载使用赞助者专用通道: ${finalDownloadUrl}`) + } catch (error) { + logger.warn(`赞助者专用通道不可用,回退普通下载通道: ${error instanceof Error ? error.message : '未知错误'}`) + } } + } else { + archiveFileName = getJavaArchiveFileName(version, process.platform, os.arch()) } // 立即返回响应,安装过程在后台进行 res.json({ success: true, - message: `${version} 开始安装${useSponsorChannel ? '(赞助者专用通道)' : ''}` + message: `${installVersion} 开始安装${providerLabel ? `(${providerLabel}${isSponsorDownload && downloadOptions?.cookie ? ',赞助者专用通道' : ''})` : ''}` }) // 后台执行安装,通过WebSocket发送进度更新 - await javaManager.installJava(version, downloadUrl, (stage, progress) => { + await javaManager.installJava(installVersion, finalDownloadUrl, (stage, progress) => { if (io && socketId) { io.to(socketId).emit('java-install-progress', { - version, + version: installVersion, stage, progress }) @@ -173,7 +163,7 @@ router.post('/java/install', authenticateToken, async (req, res) => { // 发送最终进度更新,确保进度条到达100% if (io && socketId) { io.to(socketId).emit('java-install-progress', { - version, + version: installVersion, stage: 'extract', progress: 100 }) @@ -182,20 +172,29 @@ router.post('/java/install', authenticateToken, async (req, res) => { // 安装完成通知 if (io && socketId) { io.to(socketId).emit('java-install-complete', { - version, + version: installVersion, success: true, - message: `${version} 安装成功${useSponsorChannel ? '(赞助者专用通道)' : ''}` + message: `${installVersion} 安装成功${providerLabel ? `(${providerLabel}${isSponsorDownload && downloadOptions?.cookie ? ',赞助者专用通道' : ''})` : ''}` }) } } catch (error) { logger.error(`安装 ${version} 失败:`, error) + const errorMessage = error instanceof Error ? error.message : '未知错误' + // 安装失败通知 if (io && socketId) { io.to(socketId).emit('java-install-complete', { - version, + version: installVersion, + success: false, + message: `${installVersion} 安装失败: ${errorMessage}` + }) + } + + if (!res.headersSent) { + return res.status(error instanceof UnsupportedJavaDownloadError ? 400 : 500).json({ success: false, - message: `${version} 安装失败: ${error instanceof Error ? error.message : '未知错误'}` + message: `${installVersion} 安装失败: ${errorMessage}` }) } } diff --git a/server/src/routes/gameDeployment.ts b/server/src/routes/gameDeployment.ts index 6c7a7407..e1cc0da0 100644 --- a/server/src/routes/gameDeployment.ts +++ b/server/src/routes/gameDeployment.ts @@ -21,6 +21,7 @@ import { type SteamCMDRunScript } from '../utils/steamcmdRunScript.js' import { fetchInstanceMarketList, STEAM_GAME_LIST_URL } from '../utils/remoteSources.js' +import { assertSteamCMDSupported, UnsupportedArchitectureError } from '../utils/architectureSupport.js' const execFileAsync = promisify(execFile) @@ -86,6 +87,14 @@ function getSteamUpdateCommand(appId: string, branch?: string, betaPassword?: st return command } +function getUnsupportedArchitectureResponse(error: unknown) { + return { + success: false, + error: error instanceof UnsupportedArchitectureError ? '当前架构不支持' : '架构检测失败', + message: error instanceof Error ? error.message : '当前平台暂不支持该操作' + } +} + function appendLaunchArguments(command: string, launchArgs: string): string { const normalizedCommand = command.trim() if (!launchArgs || !normalizedCommand || normalizedCommand === 'none') { @@ -902,6 +911,12 @@ router.get('/steam/branches/:appId', authenticateToken, async (req: Request, res } const refreshValue = String(req.query.refresh || '').toLowerCase() + try { + assertSteamCMDSupported() + } catch (error) { + return res.status(error instanceof UnsupportedArchitectureError ? 400 : 500).json(getUnsupportedArchitectureResponse(error)) + } + await respondWithSteamBranches(res, appId, { forceRefresh: refreshValue === '1' || refreshValue === 'true' }) @@ -932,6 +947,12 @@ router.post('/steam/branches/:appId', authenticateToken, async (req: Request, re }) } + try { + assertSteamCMDSupported() + } catch (error) { + return res.status(error instanceof UnsupportedArchitectureError ? 400 : 500).json(getUnsupportedArchitectureResponse(error)) + } + await respondWithSteamBranches(res, appId, { forceRefresh: Boolean(req.body?.forceRefresh), steamUsername, @@ -1028,6 +1049,12 @@ router.post('/steam/update', authenticateToken, async (req: AuthenticatedRequest }) } + try { + assertSteamCMDSupported() + } catch (error) { + return res.status(error instanceof UnsupportedArchitectureError ? 400 : 500).json(getUnsupportedArchitectureResponse(error)) + } + const steamcmdPath = await steamcmdManager.getSteamCMDExecutablePath() if (!steamcmdPath) { return res.status(400).json({ @@ -1330,6 +1357,12 @@ router.post('/install', authenticateToken, async (req: Request, res: Response) = return res.status(400).json({ success: false, error: error.message }) } + try { + assertSteamCMDSupported() + } catch (error) { + return res.status(error instanceof UnsupportedArchitectureError ? 400 : 500).json(getUnsupportedArchitectureResponse(error)) + } + const requestedExistingInstance = existingInstanceId ? instanceManager.getInstance(existingInstanceId) : undefined diff --git a/server/src/routes/moreGames.ts b/server/src/routes/moreGames.ts index 4e6a62b8..461ed124 100644 --- a/server/src/routes/moreGames.ts +++ b/server/src/routes/moreGames.ts @@ -6,6 +6,7 @@ import { deployTModLoaderServer, deployFactorioServer, cancelDeployment, getActi import { authenticateToken } from '../middleware/auth.js' import logger from '../utils/logger.js' import { Server as SocketIOServer } from 'socket.io' +import { assertFactorioHeadlessSupported, UnsupportedArchitectureError } from '../utils/architectureSupport.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -76,9 +77,25 @@ function getCurrentPlatform(): Platform { // 检查游戏是否支持当前平台 function isGameSupportedOnCurrentPlatform(game: GameInfo): boolean { const currentPlatform = getCurrentPlatform() + if (game.id === GameType.FACTORIO) { + try { + assertFactorioHeadlessSupported() + } catch { + return false + } + } + return game.supportedPlatforms.includes(currentPlatform) } +function getUnsupportedArchitectureResponse(error: unknown) { + return { + success: false, + error: error instanceof UnsupportedArchitectureError ? '当前架构不支持' : '架构检测失败', + message: error instanceof Error ? error.message : '当前平台暂不支持该操作' + } +} + // 支持的游戏列表 const supportedGames: GameInfo[] = [ { @@ -368,6 +385,12 @@ router.post('/deploy/factorio', authenticateToken, async (req: Request, res: Res message: '安装路径为必填项' }) } + + try { + assertFactorioHeadlessSupported() + } catch (error) { + return res.status(error instanceof UnsupportedArchitectureError ? 400 : 500).json(getUnsupportedArchitectureResponse(error)) + } const deploymentId = `factorio-deploy-${Date.now()}` @@ -547,6 +570,12 @@ router.get('/version/:gameId', authenticateToken, async (req: Request, res: Resp break } case 'factorio': { + try { + assertFactorioHeadlessSupported() + } catch (error) { + return res.status(error instanceof UnsupportedArchitectureError ? 400 : 500).json(getUnsupportedArchitectureResponse(error)) + } + // Factorio版本信息需要从其他来源获取 versionInfo = { version: 'latest', @@ -898,4 +927,4 @@ router.post('/deploy/bedrock', authenticateToken, async (req: Request, res: Resp } }) -export default router \ No newline at end of file +export default router diff --git a/server/src/routes/steamcmd.ts b/server/src/routes/steamcmd.ts index f4c58b7a..585b2b54 100644 --- a/server/src/routes/steamcmd.ts +++ b/server/src/routes/steamcmd.ts @@ -3,6 +3,7 @@ import { SteamCMDManager } from '../modules/steamcmd/SteamCMDManager.js' import { ConfigManager } from '../modules/config/ConfigManager.js' import winston from 'winston' import { authenticateToken } from '../middleware/auth.js' +import { assertSteamCMDSupported, UnsupportedArchitectureError } from '../utils/architectureSupport.js' const router = Router() let steamcmdManager: SteamCMDManager @@ -42,6 +43,15 @@ router.post('/install', authenticateToken, async (req, res) => { message: '请提供有效的安装路径' }) } + + try { + assertSteamCMDSupported() + } catch (error) { + return res.status(error instanceof UnsupportedArchitectureError ? 400 : 500).json({ + success: false, + message: error instanceof Error ? error.message : '当前平台暂不支持 SteamCMD 在线安装' + }) + } // 设置SSE响应头 res.writeHead(200, { @@ -199,4 +209,4 @@ router.get('/executable-path', authenticateToken, async (req, res) => { } }) -export default router \ No newline at end of file +export default router diff --git a/server/src/utils/architectureSupport.ts b/server/src/utils/architectureSupport.ts new file mode 100644 index 00000000..6ed05890 --- /dev/null +++ b/server/src/utils/architectureSupport.ts @@ -0,0 +1,45 @@ +import os from 'os' + +export class UnsupportedArchitectureError extends Error { + constructor(message: string) { + super(message) + this.name = 'UnsupportedArchitectureError' + } +} + +export function normalizeArchitecture(arch: string = os.arch()): string { + return arch.toLowerCase() +} + +export function isX64Architecture(arch: string = os.arch()): boolean { + const normalizedArch = normalizeArchitecture(arch) + return normalizedArch === 'x64' || normalizedArch === 'x86_64' || normalizedArch === 'amd64' +} + +export function formatPlatformArch(platform: string = os.platform(), arch: string = os.arch()): string { + return `${platform}/${arch}` +} + +export function assertSteamCMDSupported(platform: string = os.platform(), arch: string = os.arch()): void { + if (platform === 'win32') { + return + } + + if (platform === 'linux' && isX64Architecture(arch)) { + return + } + + throw new UnsupportedArchitectureError( + `当前平台/架构暂不支持 SteamCMD: ${formatPlatformArch(platform, arch)}。SteamCMD Linux 发行包依赖 x86/x86_64 运行环境;请使用 AMD64 服务器或改用手动文件部署。` + ) +} + +export function assertFactorioHeadlessSupported(platform: string = os.platform(), arch: string = os.arch()): void { + if (platform === 'linux' && isX64Architecture(arch)) { + return + } + + throw new UnsupportedArchitectureError( + `当前平台/架构暂不支持 Factorio headless 服务端自动部署: ${formatPlatformArch(platform, arch)}。Factorio 官方 headless 下载为 linux64 构建;请使用 Linux x86_64 服务器或手动提供匹配架构的服务端文件。` + ) +} diff --git a/server/src/utils/javaDownloadArtifacts.ts b/server/src/utils/javaDownloadArtifacts.ts new file mode 100644 index 00000000..e299724e --- /dev/null +++ b/server/src/utils/javaDownloadArtifacts.ts @@ -0,0 +1,846 @@ +import axios from 'axios' + +export type JavaDownloadPlatformKey = 'windows' | 'linux' | 'arm' | 'riscv64' +export type JavaReleaseChannel = 'ga' | 'ea' +export type JavaDownloadProviderId = 'sponsor' | 'adoptium' | 'azul' +export type JavaCatalogProviderId = JavaDownloadProviderId | 'system' +export type JavaCatalogOptionSource = 'download' | 'package-manager' + +type JavaArtifactMap = Record>> + +export interface JavaVersionDefinition { + id: string + major: number + label: string + description: string + packageName?: string + presets: string[] + channels: JavaReleaseChannel[] + defaultChannel: JavaReleaseChannel +} + +export interface JavaCatalogProvider { + id: JavaCatalogProviderId + label: string + description: string + source: JavaCatalogOptionSource + sponsorOnly?: boolean + supportsCustomVersion?: boolean + supportedChannels?: JavaReleaseChannel[] +} + +export interface JavaCatalogPreset { + id: string + label: string + description: string + versions: string[] +} + +export interface JavaCatalogOption { + id: string + version: string + versionLabel: string + major: number + provider: JavaCatalogProviderId + providerLabel: string + providerDescription: string + source: JavaCatalogOptionSource + releaseChannel: JavaReleaseChannel + releaseChannelLabel: string + available: boolean + recommended: boolean + sponsorOnly?: boolean + downloadUrl?: string + archiveFileName?: string + packageManager?: 'apt' + packageName?: string + unsupportedReason?: string + presets: string[] +} + +export interface JavaDownloadCatalog { + platform: string + arch: string + platformKey?: JavaDownloadPlatformKey + sponsorAvailable: boolean + providers: JavaCatalogProvider[] + presets: JavaCatalogPreset[] + versions: JavaVersionDefinition[] + options: JavaCatalogOption[] + custom: { + defaultMajor: number + defaultChannel: JavaReleaseChannel + minMajor: number + maxMajor: number + providers: JavaDownloadProviderId[] + channels: JavaReleaseChannel[] + } +} + +export interface JavaDownloadResolution { + version: string + major: number + releaseChannel: JavaReleaseChannel + provider: JavaDownloadProviderId + providerLabel: string + downloadUrl: string + archiveFileName: string + sponsorOnly?: boolean +} + +interface JavaDownloadCatalogOptions { + sponsorAvailable?: boolean +} + +interface JavaResolutionOptions extends JavaDownloadCatalogOptions { + releaseChannel?: JavaReleaseChannel +} + +interface JavaVersionRequest { + id: string + major: number + label: string + description: string + presets: string[] + packageName?: string + channels: JavaReleaseChannel[] + defaultChannel: JavaReleaseChannel + dynamic: boolean +} + +export class UnsupportedJavaDownloadError extends Error { + constructor(message: string) { + super(message) + this.name = 'UnsupportedJavaDownloadError' + } +} + +const JAVA_VERSIONS: JavaVersionDefinition[] = [ + { + id: 'java8', + major: 8, + label: 'Java 8', + description: 'Java 8 LTS,适合较老的 Minecraft/Forge 服务端', + packageName: 'openjdk-8-jre-headless', + presets: ['minecraft-legacy'], + channels: ['ga'], + defaultChannel: 'ga' + }, + { + id: 'java11', + major: 11, + label: 'Java 11', + description: 'Java 11 LTS,适合需要较新运行时但尚未迁移到 17 的服务端', + packageName: 'openjdk-11-jre-headless', + presets: ['minecraft-transitional'], + channels: ['ga'], + defaultChannel: 'ga' + }, + { + id: 'java17', + major: 17, + label: 'Java 17', + description: 'Java 17 LTS,适合 Minecraft 1.18+ 及多数现代服务端', + packageName: 'openjdk-17-jre-headless', + presets: ['minecraft-modern'], + channels: ['ga'], + defaultChannel: 'ga' + }, + { + id: 'java21', + major: 21, + label: 'Java 21', + description: 'Java 21 LTS,适合 Minecraft 1.20.5+ 及新版本服务端', + packageName: 'openjdk-21-jre-headless', + presets: ['minecraft-current'], + channels: ['ga'], + defaultChannel: 'ga' + }, + { + id: 'java25', + major: 25, + label: 'Java 25', + description: 'Java 25 LTS,适合需要最新长期支持运行时的场景', + presets: ['latest-lts'], + channels: ['ga'], + defaultChannel: 'ga' + }, + { + id: 'java27', + major: 27, + label: 'Java 27', + description: 'Java 27,适合需要最新功能版运行时的实验性场景', + presets: ['latest-feature'], + channels: ['ga'], + defaultChannel: 'ga' + }, + { + id: 'java28-ea', + major: 28, + label: 'Java 28 EA', + description: 'Java 28 Early Access,用于测试未来版本兼容性', + presets: ['early-access'], + channels: ['ea'], + defaultChannel: 'ea' + } +] + +const JAVA_PRESETS: JavaCatalogPreset[] = [ + { + id: 'minecraft-legacy', + label: '旧版 Minecraft', + description: 'Minecraft 1.16 及更早版本常用', + versions: ['java8'] + }, + { + id: 'minecraft-modern', + label: '现代 Minecraft', + description: 'Minecraft 1.18 到 1.20.4 常用', + versions: ['java17'] + }, + { + id: 'minecraft-current', + label: '当前 Minecraft', + description: 'Minecraft 1.20.5+ 常用', + versions: ['java21'] + }, + { + id: 'latest-lts', + label: '最新 LTS', + description: '优先选择长期支持版本', + versions: ['java25'] + }, + { + id: 'latest-feature', + label: '最新功能版', + description: '用于测试需要新 Java 功能的服务端', + versions: ['java27'] + }, + { + id: 'early-access', + label: '预览 / EA', + description: '用于验证未来 Java 版本', + versions: ['java28-ea'] + }, + { + id: 'custom-version', + label: '自定义版本', + description: '用户手动选择的 Java 主版本', + versions: [] + } +] + +const JAVA_PROVIDERS: JavaCatalogProvider[] = [ + { + id: 'sponsor', + label: '赞助高速源', + description: '项目提供的国内高速下载源,需要本地记录赞助者密钥', + source: 'download', + sponsorOnly: true, + supportedChannels: ['ga'] + }, + { + id: 'adoptium', + label: 'Eclipse Temurin', + description: 'Eclipse Adoptium 官方 latest JDK API,支持 GA 与 EA 通道', + source: 'download', + supportsCustomVersion: true, + supportedChannels: ['ga', 'ea'] + }, + { + id: 'azul', + label: 'Azul Zulu', + description: 'Azul Metadata API,安装时解析最新匹配的 Zulu JDK 包', + source: 'download', + supportsCustomVersion: true, + supportedChannels: ['ga', 'ea'] + }, + { + id: 'system', + label: '系统包管理器', + description: '通过 Linux 发行版的软件仓库安装 OpenJDK,适合 riscv64 等特殊架构', + source: 'package-manager', + supportedChannels: ['ga'] + } +] + +const SPONSOR_DOWNLOAD_URLS: JavaArtifactMap = { + java8: { + windows: 'https://download.xiaozhuhouses.asia/d/e2fb4833ba415b8c500aed2d9b88d401/openjdk-8u44-windows-i586.zip', + linux: 'https://download.xiaozhuhouses.asia/d/eba19d9b0eb2f5ee0af4aa1410e7e8ea/openjdk-8u44-linux-x64.tar.gz' + }, + java11: { + windows: 'https://download.xiaozhuhouses.asia/d/09844c5699181cace0c50838a01b3afa/openjdk-11.0.0.2_windows-x64.zip', + linux: 'https://download.xiaozhuhouses.asia/d/1ad2d19275d387bf474186213159971b/openjdk-11.0.0.2_linux-x64.tar.gz' + }, + java17: { + windows: 'https://download.xiaozhuhouses.asia/d/7dc046a7855530363fac794781bdf767/openjdk-17.0.0.1+2_windows-x64_bin.zip', + linux: 'https://download.xiaozhuhouses.asia/d/25b171ae761d3222bd0f91ddca373d32/openjdk-17.0.0.1+2_linux-x64_bin.tar.gz', + arm: 'https://download.xiaozhuhouses.asia/d/8868121de8e1a36192abfe4034ec3a7b/openjdk-17.0.2_linux-aarch64_bin.tar.gz' + }, + java21: { + windows: 'https://download.xiaozhuhouses.asia/d/a436ba580cf68550b0a14408105eb8d5/openjdk-21+35_windows-x64_bin.zip', + linux: 'https://download.xiaozhuhouses.asia/d/521a8c9551a8cd0923825789e3be5054/openjdk-21+35_linux-x64_bin.tar.gz', + arm: 'https://download.xiaozhuhouses.asia/d/4c5d060a6186d630f4c0cb64cac7c075/openjdk-21_linux-aarch64_bin.tar.gz' + }, + java25: { + windows: 'https://download.xiaozhuhouses.asia/d/1e1b2424fef706eb9f13851ad9081aff/openjdk-25+36_windows-x64_bin.zip', + linux: 'https://download.xiaozhuhouses.asia/d/59e59a84f693481bb7a29802a2ef5253/openjdk-25+36_linux-x64_bin.tar.gz', + arm: 'https://download.xiaozhuhouses.asia/d/a6f7a6c56d31bcb9c47927fb55386343/openjdk-25.0.2_linux-aarch64_bin.tar.gz' + }, + java27: { + windows: 'https://download.xiaozhuhouses.asia/d/b1b2ce98fd714e8202a6d0cd8893237e/openjdk-27+35_windows-x64_bin.zip', + linux: 'https://download.xiaozhuhouses.asia/d/57849c8d615de1b355d88ba2fa2d6bf2/openjdk-27+35_linux-x64_bin.tar.gz' + } +} + +const JAVA_ARCHIVE_FILE_NAMES: JavaArtifactMap = { + java8: { + windows: 'openjdk-8u44-windows-i586.zip', + linux: 'openjdk-8u44-linux-x64.tar.gz' + }, + java11: { + windows: 'openjdk-11.0.0.2_windows-x64.zip', + linux: 'openjdk-11.0.0.2_linux-x64.tar.gz' + }, + java17: { + windows: 'openjdk-17.0.0.1+2_windows-x64_bin.zip', + linux: 'openjdk-17.0.0.1+2_linux-x64_bin.tar.gz', + arm: 'openjdk-17.0.2_linux-aarch64_bin.tar.gz' + }, + java21: { + windows: 'openjdk-21+35_windows-x64_bin.zip', + linux: 'openjdk-21+35_linux-x64_bin.tar.gz', + arm: 'openjdk-21_linux-aarch64_bin.tar.gz' + }, + java25: { + windows: 'openjdk-25+36_windows-x64_bin.zip', + linux: 'openjdk-25+36_linux-x64_bin.tar.gz', + arm: 'openjdk-25.0.2_linux-aarch64_bin.tar.gz' + }, + java27: { + windows: 'openjdk-27+35_windows-x64_bin.zip', + linux: 'openjdk-27+35_linux-x64_bin.tar.gz' + } +} + +const ADOPTIUM_OS_BY_PLATFORM: Record = { + windows: 'windows', + linux: 'linux', + arm: 'linux', + riscv64: 'linux' +} + +const ADOPTIUM_ARCH_BY_PLATFORM: Record = { + windows: 'x64', + linux: 'x64', + arm: 'aarch64', + riscv64: 'riscv64' +} + +const ADOPTIUM_SUPPORTED_KNOWN_VERSIONS_BY_PLATFORM: Record = { + windows: ['java8', 'java11', 'java17', 'java21', 'java25', 'java27', 'java28-ea'], + linux: ['java8', 'java11', 'java17', 'java21', 'java25', 'java27', 'java28-ea'], + arm: ['java8', 'java11', 'java17', 'java21', 'java25', 'java27', 'java28-ea'], + riscv64: ['java17', 'java21', 'java25', 'java27', 'java28-ea'] +} + +const AZUL_OS_BY_PLATFORM: Partial> = { + windows: 'windows', + linux: 'linux', + arm: 'linux' +} + +const AZUL_ARCH_BY_PLATFORM: Partial> = { + windows: 'x64', + linux: 'x64', + arm: 'aarch64' +} + +function formatPlatformArch(platform: string, arch?: string): string { + return `${platform}/${arch || 'unknown'}` +} + +function getReleaseChannelLabel(channel: JavaReleaseChannel): string { + return channel === 'ea' ? 'EA / 预览版' : 'GA / 稳定版' +} + +function normalizeReleaseChannel(channel?: string): JavaReleaseChannel { + return channel === 'ea' ? 'ea' : 'ga' +} + +function unsupportedJavaDownloadMessage(platform: string, arch?: string): string { + return `当前平台/架构暂不支持下载式 Java 安装: ${formatPlatformArch(platform, arch)}。请使用系统包管理器安装 OpenJDK,并在实例中使用系统 PATH 中的 java。` +} + +function getProvider(provider: JavaCatalogProviderId): JavaCatalogProvider { + const javaProvider = JAVA_PROVIDERS.find(item => item.id === provider) + if (!javaProvider) { + throw new UnsupportedJavaDownloadError(`不支持的 Java 下载源: ${provider}`) + } + + return javaProvider +} + +function getVersionInstallId(major: number, channel: JavaReleaseChannel): string { + return channel === 'ea' ? `java${major}-ea` : `java${major}` +} + +function parseJavaVersionRequest(version: string, releaseChannel?: JavaReleaseChannel): JavaVersionRequest { + const predefined = JAVA_VERSIONS.find(item => item.id === version) + if (predefined) { + const channel = releaseChannel || predefined.defaultChannel + if (!predefined.channels.includes(channel)) { + throw new UnsupportedJavaDownloadError(`${predefined.label} 不支持 ${getReleaseChannelLabel(channel)} 通道`) + } + + return { + ...predefined, + defaultChannel: channel, + dynamic: false + } + } + + const match = /^java(\d+)(?:-(ga|ea))?$/i.exec(version) + if (!match) { + throw new UnsupportedJavaDownloadError(`不支持的 Java 版本: ${version}`) + } + + const major = Number(match[1]) + if (!Number.isInteger(major) || major < 8 || major > 99) { + throw new UnsupportedJavaDownloadError(`Java 主版本号超出支持范围: ${version}`) + } + + const channel = releaseChannel || normalizeReleaseChannel(match[2]) + const id = getVersionInstallId(major, channel) + + return { + id, + major, + label: `Java ${major}${channel === 'ea' ? ' EA' : ''}`, + description: channel === 'ea' + ? `Java ${major} Early Access,自定义预览版运行时` + : `Java ${major},自定义稳定版运行时`, + presets: channel === 'ea' ? ['early-access'] : ['custom-version'], + channels: ['ga', 'ea'], + defaultChannel: channel, + dynamic: true + } +} + +export function getJavaDownloadPlatformKey(platform: string, arch?: string): JavaDownloadPlatformKey { + const normalizedArch = (arch || '').toLowerCase() + + if (platform === 'win32') { + if (!normalizedArch || normalizedArch === 'x64' || normalizedArch === 'x86_64' || normalizedArch === 'amd64') { + return 'windows' + } + + throw new UnsupportedJavaDownloadError(unsupportedJavaDownloadMessage(platform, arch)) + } + + if (platform !== 'linux') { + throw new UnsupportedJavaDownloadError(unsupportedJavaDownloadMessage(platform, arch)) + } + + if (normalizedArch === 'x64' || normalizedArch === 'x86_64' || normalizedArch === 'amd64') { + return 'linux' + } + + if (normalizedArch === 'arm64' || normalizedArch === 'aarch64') { + return 'arm' + } + + if (normalizedArch === 'riscv64') { + return 'riscv64' + } + + throw new UnsupportedJavaDownloadError(unsupportedJavaDownloadMessage(platform, arch)) +} + +function getJavaArtifact( + artifacts: JavaArtifactMap, + version: string, + platform: string, + arch: string | undefined, + artifactType: string +): string { + const platformKey = getJavaDownloadPlatformKey(platform, arch) + const artifact = artifacts[version]?.[platformKey] + + if (!artifact) { + throw new UnsupportedJavaDownloadError( + `${artifactType} 不支持当前 Java 版本或平台/架构组合: ${version}, ${formatPlatformArch(platform, arch)}。请使用其它提供商或系统包管理器安装 OpenJDK。` + ) + } + + return artifact +} + +function getAdoptiumDownloadUrl(version: JavaVersionRequest, platform: string, arch?: string): string { + const platformKey = getJavaDownloadPlatformKey(platform, arch) + if (!version.dynamic && !ADOPTIUM_SUPPORTED_KNOWN_VERSIONS_BY_PLATFORM[platformKey].includes(version.id)) { + throw new UnsupportedJavaDownloadError( + `Eclipse Temurin 暂不提供当前 Java 版本或平台/架构组合: ${version.id}, ${formatPlatformArch(platform, arch)}。请改用其它版本或系统包管理器安装 OpenJDK。` + ) + } + + const osName = ADOPTIUM_OS_BY_PLATFORM[platformKey] + const apiArch = ADOPTIUM_ARCH_BY_PLATFORM[platformKey] + + return `https://api.adoptium.net/v3/binary/latest/${version.major}/${version.defaultChannel}/${osName}/${apiArch}/jdk/hotspot/normal/eclipse` +} + +function getAdoptiumArchiveFileName(version: JavaVersionRequest, platform: string, arch?: string): string { + const platformKey = getJavaDownloadPlatformKey(platform, arch) + if (!version.dynamic && !ADOPTIUM_SUPPORTED_KNOWN_VERSIONS_BY_PLATFORM[platformKey].includes(version.id)) { + throw new UnsupportedJavaDownloadError( + `Eclipse Temurin 暂不提供当前 Java 版本或平台/架构组合: ${version.id}, ${formatPlatformArch(platform, arch)}。请改用其它版本或系统包管理器安装 OpenJDK。` + ) + } + + const osName = ADOPTIUM_OS_BY_PLATFORM[platformKey] + const apiArch = ADOPTIUM_ARCH_BY_PLATFORM[platformKey] + const extension = platformKey === 'windows' ? 'zip' : 'tar.gz' + + return `temurin-${version.major}-${version.defaultChannel}-${osName}-${apiArch}.jdk.${extension}` +} + +function getAzulPlatform(platform: string, arch?: string): { osName: string; archName: string; archiveType: string } { + const platformKey = getJavaDownloadPlatformKey(platform, arch) + const osName = AZUL_OS_BY_PLATFORM[platformKey] + const archName = AZUL_ARCH_BY_PLATFORM[platformKey] + + if (!osName || !archName) { + throw new UnsupportedJavaDownloadError(`Azul Zulu 暂不支持当前平台/架构组合: ${formatPlatformArch(platform, arch)}`) + } + + return { + osName, + archName, + archiveType: platformKey === 'windows' ? 'zip' : 'tar.gz' + } +} + +function getFileNameFromUrl(url: string, fallback: string): string { + try { + const fileName = new URL(url).pathname.split('/').filter(Boolean).pop() + return fileName || fallback + } catch { + return fallback + } +} + +async function resolveAzulDownload(version: JavaVersionRequest, platform: string, arch?: string): Promise<{ downloadUrl: string; archiveFileName: string }> { + const { osName, archName, archiveType } = getAzulPlatform(platform, arch) + const params = new URLSearchParams({ + java_version: String(version.major), + os: osName, + arch: archName, + archive_type: archiveType, + java_package_type: 'jdk', + javafx_bundled: 'false', + crac_supported: 'false', + release_status: version.defaultChannel, + availability_types: 'CA', + latest: 'true', + page: '1', + page_size: '1' + }) + + const response = await axios.get(`https://api.azul.com/metadata/v1/zulu/packages/?${params.toString()}`, { + timeout: 15000, + headers: { + Accept: 'application/json', + 'User-Agent': 'GSManager3/1.0.0' + } + }) + + const packageInfo = Array.isArray(response.data) ? response.data[0] : undefined + const downloadUrl = typeof packageInfo?.download_url === 'string' ? packageInfo.download_url : '' + if (!downloadUrl) { + throw new UnsupportedJavaDownloadError( + `Azul Zulu 未找到匹配的 Java ${version.major} ${getReleaseChannelLabel(version.defaultChannel)} 包: ${formatPlatformArch(platform, arch)}` + ) + } + + return { + downloadUrl, + archiveFileName: typeof packageInfo.name === 'string' + ? packageInfo.name + : getFileNameFromUrl(downloadUrl, `zulu-${version.major}-${version.defaultChannel}-${osName}-${archName}.${archiveType}`) + } +} + +function buildUnavailableDownloadOption( + version: JavaVersionDefinition, + provider: JavaCatalogProvider, + platform: string, + arch: string | undefined, + channel: JavaReleaseChannel, + recommended: boolean, + message: string +): JavaCatalogOption { + return { + id: `${version.id}:${provider.id}:${channel}`, + version: version.id, + versionLabel: version.label, + major: version.major, + provider: provider.id, + providerLabel: provider.label, + providerDescription: provider.description, + source: 'download', + releaseChannel: channel, + releaseChannelLabel: getReleaseChannelLabel(channel), + available: false, + recommended, + sponsorOnly: provider.sponsorOnly, + unsupportedReason: message || unsupportedJavaDownloadMessage(platform, arch), + presets: version.presets + } +} + +function buildDownloadOption( + version: JavaVersionDefinition, + provider: JavaCatalogProvider, + platform: string, + arch: string | undefined, + sponsorAvailable: boolean, + channel: JavaReleaseChannel, + recommended: boolean +): JavaCatalogOption { + if (!provider.supportedChannels?.includes(channel)) { + return buildUnavailableDownloadOption(version, provider, platform, arch, channel, recommended, `${provider.label} 不支持 ${getReleaseChannelLabel(channel)} 通道`) + } + + if (provider.id === 'sponsor' && !sponsorAvailable) { + return buildUnavailableDownloadOption( + version, + provider, + platform, + arch, + channel, + recommended, + '赞助高速源需要先配置本地赞助者密钥' + ) + } + + if (provider.id === 'azul') { + try { + getAzulPlatform(platform, arch) + return { + id: `${version.id}:${provider.id}:${channel}`, + version: version.id, + versionLabel: version.label, + major: version.major, + provider: provider.id, + providerLabel: provider.label, + providerDescription: provider.description, + source: 'download', + releaseChannel: channel, + releaseChannelLabel: getReleaseChannelLabel(channel), + available: true, + recommended, + presets: version.presets + } + } catch (error) { + return buildUnavailableDownloadOption( + version, + provider, + platform, + arch, + channel, + recommended, + error instanceof Error ? error.message : unsupportedJavaDownloadMessage(platform, arch) + ) + } + } + + try { + const request = parseJavaVersionRequest(version.id, channel) + const downloadUrl = provider.id === 'sponsor' + ? getSponsorDownloadUrl(version.id, platform, arch) + : getAdoptiumDownloadUrl(request, platform, arch) + const archiveFileName = provider.id === 'sponsor' + ? getJavaArchiveFileName(version.id, platform, arch) + : getAdoptiumArchiveFileName(request, platform, arch) + + return { + id: `${version.id}:${provider.id}:${channel}`, + version: version.id, + versionLabel: version.label, + major: version.major, + provider: provider.id, + providerLabel: provider.label, + providerDescription: provider.description, + source: 'download', + releaseChannel: channel, + releaseChannelLabel: getReleaseChannelLabel(channel), + available: true, + recommended, + sponsorOnly: provider.sponsorOnly, + downloadUrl, + archiveFileName, + presets: version.presets + } + } catch (error) { + const message = error instanceof Error ? error.message : unsupportedJavaDownloadMessage(platform, arch) + return buildUnavailableDownloadOption(version, provider, platform, arch, channel, recommended, message) + } +} + +function buildSystemPackageOption( + version: JavaVersionDefinition, + platform: string, + arch: string | undefined, + recommended: boolean +): JavaCatalogOption { + const provider = getProvider('system') + const available = platform === 'linux' && Boolean(version.packageName) && version.defaultChannel === 'ga' + const isRiscv64 = platform === 'linux' && (arch || '').toLowerCase() === 'riscv64' + + return { + id: `${version.id}:system:ga`, + version: version.id, + versionLabel: version.label, + major: version.major, + provider: provider.id, + providerLabel: provider.label, + providerDescription: provider.description, + source: 'package-manager', + releaseChannel: 'ga', + releaseChannelLabel: getReleaseChannelLabel('ga'), + available, + recommended: recommended || isRiscv64, + packageManager: available ? 'apt' : undefined, + packageName: available ? version.packageName : undefined, + unsupportedReason: available ? undefined : '当前版本没有内置的系统包管理器预设', + presets: version.presets + } +} + +export function getJavaDownloadCatalog( + platform: string, + arch?: string, + options: JavaDownloadCatalogOptions = {} +): JavaDownloadCatalog { + const sponsorAvailable = Boolean(options.sponsorAvailable) + let platformKey: JavaDownloadPlatformKey | undefined + + try { + platformKey = getJavaDownloadPlatformKey(platform, arch) + } catch (error) { + platformKey = undefined + } + + const catalogOptions: JavaCatalogOption[] = [] + const isRiscv64 = platform === 'linux' && (arch || '').toLowerCase() === 'riscv64' + + for (const version of JAVA_VERSIONS) { + for (const provider of JAVA_PROVIDERS.filter(item => item.source === 'download')) { + for (const channel of version.channels) { + const recommended = ( + (provider.id === 'sponsor' && sponsorAvailable && channel === version.defaultChannel) || + (provider.id === 'adoptium' && !sponsorAvailable && channel === version.defaultChannel) + ) + catalogOptions.push(buildDownloadOption(version, provider, platform, arch, sponsorAvailable, channel, recommended)) + } + } + + catalogOptions.push(buildSystemPackageOption(version, platform, arch, isRiscv64)) + } + + return { + platform, + arch: arch || 'unknown', + platformKey, + sponsorAvailable, + providers: JAVA_PROVIDERS, + presets: JAVA_PRESETS, + versions: JAVA_VERSIONS, + options: catalogOptions, + custom: { + defaultMajor: 25, + defaultChannel: 'ga', + minMajor: 8, + maxMajor: 99, + providers: ['adoptium', 'azul'], + channels: ['ga', 'ea'] + } + } +} + +export async function resolveJavaDownloadOption( + version: string, + provider: JavaDownloadProviderId, + platform: string, + arch?: string, + options: JavaResolutionOptions = {} +): Promise { + const providerDefinition = getProvider(provider) + const versionRequest = parseJavaVersionRequest(version, options.releaseChannel) + + if (!providerDefinition.supportedChannels?.includes(versionRequest.defaultChannel)) { + throw new UnsupportedJavaDownloadError(`${providerDefinition.label} 不支持 ${getReleaseChannelLabel(versionRequest.defaultChannel)} 通道`) + } + + if (provider === 'sponsor') { + if (versionRequest.dynamic) { + throw new UnsupportedJavaDownloadError('赞助高速源仅支持项目维护的预设版本') + } + + if (!options.sponsorAvailable) { + throw new UnsupportedJavaDownloadError('赞助高速源需要先配置本地赞助者密钥') + } + + return { + version: versionRequest.id, + major: versionRequest.major, + releaseChannel: versionRequest.defaultChannel, + provider, + providerLabel: providerDefinition.label, + sponsorOnly: true, + downloadUrl: getSponsorDownloadUrl(versionRequest.id, platform, arch), + archiveFileName: getJavaArchiveFileName(versionRequest.id, platform, arch) + } + } + + if (provider === 'adoptium') { + return { + version: versionRequest.id, + major: versionRequest.major, + releaseChannel: versionRequest.defaultChannel, + provider, + providerLabel: providerDefinition.label, + downloadUrl: getAdoptiumDownloadUrl(versionRequest, platform, arch), + archiveFileName: getAdoptiumArchiveFileName(versionRequest, platform, arch) + } + } + + if (provider === 'azul') { + const resolved = await resolveAzulDownload(versionRequest, platform, arch) + return { + version: versionRequest.id, + major: versionRequest.major, + releaseChannel: versionRequest.defaultChannel, + provider, + providerLabel: providerDefinition.label, + downloadUrl: resolved.downloadUrl, + archiveFileName: resolved.archiveFileName + } + } + + throw new UnsupportedJavaDownloadError(`不支持的 Java 下载源: ${provider}`) +} + +export function getSponsorDownloadUrl(version: string, platform: string, arch?: string): string { + return getJavaArtifact(SPONSOR_DOWNLOAD_URLS, version, platform, arch, 'Java 赞助版下载链接') +} + +export function getJavaArchiveFileName(version: string, platform: string, arch?: string): string { + return getJavaArtifact(JAVA_ARCHIVE_FILE_NAMES, version, platform, arch, 'Java 压缩包') +} + +export function getSupportedJavaVersions(): JavaVersionDefinition[] { + return JAVA_VERSIONS +} diff --git a/server/src/utils/steamcmdRunScript.ts b/server/src/utils/steamcmdRunScript.ts index 511d14d3..6b1ddd81 100644 --- a/server/src/utils/steamcmdRunScript.ts +++ b/server/src/utils/steamcmdRunScript.ts @@ -5,8 +5,34 @@ import { randomUUID } from 'crypto' const STEAMCMD_SCRIPT_MAX_LIFETIME_MS = 35 * 60 * 1000 -function getSteamCMDTaskDirectory(): string { - return path.join(os.tmpdir(), 'gsm3-steamcmd', 'tasks') +function getSteamCMDTaskDirectoryCandidates(): string[] { + return [ + path.join(os.tmpdir(), 'gsm3-steamcmd', 'tasks'), + path.join(process.cwd(), 'data', 'tmp', 'gsm3-steamcmd', 'tasks') + ] +} + +async function prepareSteamCMDTaskDirectory(clean = false): Promise { + let lastError: unknown + + for (const taskDirectory of getSteamCMDTaskDirectoryCandidates()) { + try { + if (clean) { + await fs.rm(taskDirectory, { recursive: true, force: true }) + } + + await fs.mkdir(taskDirectory, { recursive: true, mode: 0o700 }) + await fs.chmod(taskDirectory, 0o700) + return taskDirectory + } catch (error: any) { + lastError = error + if (error?.code !== 'EACCES' && error?.code !== 'EPERM') { + throw error + } + } + } + + throw lastError instanceof Error ? lastError : new Error('无法创建SteamCMD临时脚本目录') } export interface SteamCMDRunScript { @@ -20,10 +46,7 @@ export function quoteSteamCMDConsoleArgument(value: string): string { } export async function cleanupSteamCMDRunScripts(): Promise { - const taskDirectory = getSteamCMDTaskDirectory() - await fs.rm(taskDirectory, { recursive: true, force: true }) - await fs.mkdir(taskDirectory, { recursive: true, mode: 0o700 }) - await fs.chmod(taskDirectory, 0o700) + await prepareSteamCMDTaskDirectory(true) } export async function prepareSteamCMDLaunch(executablePath: string): Promise { @@ -59,12 +82,10 @@ export async function createSteamCMDRunScript( throw new Error('SteamCMD脚本命令格式无效') } - const taskDirectory = getSteamCMDTaskDirectory() - await fs.mkdir(taskDirectory, { recursive: true, mode: 0o700 }) + const taskDirectory = await prepareSteamCMDTaskDirectory() const taskPath = path.join(taskDirectory, randomUUID()) const logDirectory = path.join(taskPath, 'logs') const filePath = path.join(taskPath, 'commands.txt') - await fs.chmod(taskDirectory, 0o700) await fs.mkdir(taskPath, { mode: 0o700 }) await fs.mkdir(logDirectory, { recursive: true, mode: 0o700 }) const content = [ diff --git a/server/src/utils/zipToolsManager.ts b/server/src/utils/zipToolsManager.ts index e8c74db9..6484d1bb 100644 --- a/server/src/utils/zipToolsManager.ts +++ b/server/src/utils/zipToolsManager.ts @@ -1,7 +1,7 @@ import { spawn } from 'child_process' import path from 'path' import fs from 'fs/promises' -import { createWriteStream } from 'fs' +import { constants as fsConstants, createWriteStream } from 'fs' import { pipeline } from 'stream/promises' import logger from './logger.js' import { directoryContainsCorruptedNames } from './filenameEncoding.js' @@ -92,6 +92,56 @@ class ZipToolsManager { private readonly DOWNLOAD_URL = 'https://github.com/MCSManager/Zip-Tools/releases/latest/download/' + private isBundledBinarySupported(): boolean { + return SUPPORTED_PLATFORMS.has(process.platform) && SUPPORTED_ARCHS.has(process.arch) + } + + private getSystemCommandCandidates(command: string): string[] { + if (process.platform !== 'win32' || path.extname(command)) { + return [command] + } + + const pathExt = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM') + .split(';') + .filter(Boolean) + return pathExt.map(ext => `${command}${ext.toLowerCase()}`) + } + + private async findSystemCommand(commands: string[]): Promise { + const pathEnv = process.env.PATH + if (!pathEnv) { + return null + } + + for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) { + for (const command of commands) { + for (const candidateName of this.getSystemCommandCandidates(command)) { + const candidatePath = path.join(dir, candidateName) + try { + await fs.access(candidatePath, fsConstants.F_OK) + return candidatePath + } catch { + // 尝试下一个 PATH 候选 + } + } + } + } + + return null + } + + private async getSystem7zPath(): Promise { + return this.findSystemCommand(['7z', '7zz']) + } + + private async getSystemUnzipPath(): Promise { + return this.findSystemCommand(['unzip']) + } + + private async getSystemZipPath(): Promise { + return this.findSystemCommand(['zip']) + } + /** * 获取当前平台对应的二进制文件名 * 规则: file_zip_{platform}_{arch},Windows 追加 .exe @@ -246,6 +296,16 @@ class ZipToolsManager { logger.info('Zip-Tools 已存在,跳过下载') return } + if (!this.isBundledBinarySupported()) { + const unzipPath = await this.getSystemUnzipPath() + const zipPath = await this.getSystemZipPath() + if (unzipPath || zipPath) { + logger.info(`当前平台未提供内置 Zip-Tools,使用系统 ZIP 工具降级: unzip=${unzipPath || '未找到'}, zip=${zipPath || '未找到'}`) + } else { + logger.warn(`当前平台未提供内置 Zip-Tools,且未找到系统 unzip/zip;ZIP 压缩解压功能将不可用: ${process.platform}/${process.arch}`) + } + return + } await this.download() } @@ -358,6 +418,15 @@ class ZipToolsManager { logger.info('7z 已存在,跳过下载') return } + if (!this.isBundledBinarySupported()) { + const system7zPath = await this.getSystem7zPath() + if (system7zPath) { + logger.info(`当前平台未提供内置 7z,使用系统 7z 降级: ${system7zPath}`) + } else { + logger.warn(`当前平台未提供内置 7z,且未找到系统 7z/7zz;7z 功能将不可用: ${process.platform}/${process.arch}`) + } + return + } await this.download7z() } @@ -418,6 +487,98 @@ class ZipToolsManager { }) } + private async get7zExecutablePath(): Promise { + try { + return await this.get7zPath() + } catch (error: any) { + const system7zPath = await this.getSystem7zPath() + if (system7zPath) { + logger.info(`使用系统 7z: ${system7zPath}`) + return system7zPath + } + + if (this.isBundledBinarySupported()) { + throw error + } + + throw new Error( + `当前平台没有可内置的 7z 二进制文件,且 PATH 中未找到 7z/7zz。请安装 p7zip-full 或 7zip 后重试。平台: ${process.platform}/${process.arch}` + ) + } + } + + private executeSystemTool(toolName: string, toolPath: string, args: string[], cwd?: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(toolPath, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }) + + let stderr = '' + + child.stderr.on('data', (data: Buffer) => { + stderr += data.toString() + }) + + child.on('error', (error: NodeJS.ErrnoException) => { + reject(this.buildProcessStartError(toolName, toolPath, error)) + }) + + child.on('close', (code: number | null) => { + if (code === 0) { + resolve() + } else { + reject( + new Error( + `${toolName} 执行失败 (退出码: ${code}): ${stderr.trim() || '未知错误'}` + ) + ) + } + }) + }) + } + + private async extractZipWithSystemUnzip( + zipPath: string, + targetDir: string, + originalError?: Error + ): Promise { + const unzipPath = await this.getSystemUnzipPath() + if (!unzipPath) { + const suffix = originalError ? ` 原始错误: ${originalError.message}` : '' + throw new Error( + `当前平台没有可用的内置 Zip-Tools,且 PATH 中未找到 unzip。请安装 unzip 后重试。平台: ${process.platform}/${process.arch}.${suffix}` + ) + } + + logger.warn(`使用系统 unzip 处理 ZIP 文件,非 UTF-8 文件名不会执行 GBK 回退: ${zipPath}`) + await fs.mkdir(targetDir, { recursive: true }) + const tempTargetDir = await createZipExtractTempDir(targetDir) + + try { + await this.executeSystemTool('unzip', unzipPath, ['-oq', zipPath, '-d', path.resolve(tempTargetDir)]) + await moveDirectoryContents(tempTargetDir, targetDir) + } finally { + await fs.rm(tempTargetDir, { recursive: true, force: true }) + } + } + + private async compressZipWithSystemZip( + zipPath: string, + files: string[], + cwd: string, + originalError?: Error + ): Promise { + const systemZipPath = await this.getSystemZipPath() + if (!systemZipPath) { + const suffix = originalError ? ` 原始错误: ${originalError.message}` : '' + throw new Error( + `当前平台没有可用的内置 Zip-Tools,且 PATH 中未找到 zip。请安装 zip 后重试。平台: ${process.platform}/${process.arch}.${suffix}` + ) + } + + logger.warn(`使用系统 zip 处理 ZIP 压缩,编码处理能力可能弱于内置 Zip-Tools: ${zipPath}`) + const zipFileName = path.basename(zipPath) + await this.executeSystemTool('zip', systemZipPath, ['-r', zipFileName, ...files], cwd) + } + /** * 执行 7z 解压操作 * 命令: 7z x {archivePath} -o{targetDir} @@ -426,7 +587,7 @@ class ZipToolsManager { */ async extract7z(archivePath: string, targetDir: string): Promise { await this.ensure7zInstalled() - const toolPath = await this.get7zPath() + const toolPath = await this.get7zExecutablePath() // 确保目标目录存在 await fs.mkdir(targetDir, { recursive: true }) @@ -445,7 +606,7 @@ class ZipToolsManager { */ async compress7z(archivePath: string, files: string[], cwd: string): Promise { await this.ensure7zInstalled() - const toolPath = await this.get7zPath() + const toolPath = await this.get7zExecutablePath() const args = ['a', archivePath, ...files] @@ -463,7 +624,18 @@ class ZipToolsManager { * 优先尝试 UTF-8,若检测到损坏文件名或首次解压失败则回退到 GBK */ async extractZip(zipPath: string, targetDir: string): Promise { - const toolPath = await this.getZipToolsPath() + let toolPath: string + try { + toolPath = await this.getZipToolsPath() + } catch (error: any) { + await this.extractZipWithSystemUnzip( + zipPath, + targetDir, + error instanceof Error ? error : new Error(String(error)) + ) + return + } + const zipDir = path.dirname(zipPath) const zipFileName = path.basename(zipPath) @@ -527,7 +699,19 @@ class ZipToolsManager { * cwd 设置为待压缩文件所在目录 */ async compressZip(zipPath: string, files: string[], cwd: string): Promise { - const toolPath = await this.getZipToolsPath() + let toolPath: string + try { + toolPath = await this.getZipToolsPath() + } catch (error: any) { + await this.compressZipWithSystemZip( + zipPath, + files, + cwd, + error instanceof Error ? error : new Error(String(error)) + ) + return + } + const zipFileName = path.basename(zipPath) const args = [ diff --git a/start.sh b/start.sh index 37d087db..27da7bfa 100644 --- a/start.sh +++ b/start.sh @@ -45,12 +45,14 @@ if [ -f "server/index.js" ]; then # PTY 文件已迁移到 data/lib/ 目录,启动时由服务端自动检测和下载 # 如果 data/lib/ 中存在 PTY 文件,验证并设置可执行权限 ARCH=$(uname -m) + PTY_SUPPORTED="yes" if [ "$ARCH" = "x86_64" ]; then PTY_FILE="$RUNTIME_LIB_DIR/pty_linux_x64" elif [ "$ARCH" = "aarch64" ]; then PTY_FILE="$RUNTIME_LIB_DIR/pty_linux_arm64" else PTY_FILE="" + PTY_SUPPORTED="no" fi if [ -n "$PTY_FILE" ] && [ -f "$PTY_FILE" ]; then @@ -68,6 +70,8 @@ if [ -f "server/index.js" ]; then echo "✅ PTY权限设置完成 ($ARCH)" echo "ℹ️ 未安装 file 命令,已跳过PTY启动预检,服务端将继续执行固定清单校验" fi + elif [ "$PTY_SUPPORTED" = "no" ]; then + echo "ℹ️ 当前架构 ($ARCH) 暂无固定PTY资产,终端功能可能不可用" else echo "ℹ️ PTY文件将在服务启动时自动下载" fi @@ -86,7 +90,7 @@ else if [ "$ARCH" = "x86_64" ]; then echo "1. 启动SteamCMD" else - echo "1. SteamCMD (不支持ARM64架构)" + echo "1. SteamCMD (不支持当前架构: $ARCH)" fi echo "2. 查看游戏目录" echo "3. 退出" @@ -100,8 +104,8 @@ else cd ${STEAMCMD_DIR} ./steamcmd.sh else - echo "❌ SteamCMD不支持ARM64架构" - echo "💡 ARM64版本仅支持GSM3管理面板功能" + echo "❌ SteamCMD不支持当前架构: $ARCH" + echo "💡 非 x86_64 架构仅支持GSM3管理面板功能" fi ;; 2)