diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 6cc053e7..8d7ab179 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -78,7 +78,7 @@ jobs: - os: macos-15 name: macos-arm64 target: aarch64-apple-darwin - build_command: npm run build:web && cd src/apps/desktop && npm exec -- tauri build --target aarch64-apple-darwin --bundles dmg + build_command: cd src/apps/desktop && npm exec -- tauri build --target aarch64-apple-darwin --bundles dmg - os: macos-15-intel name: macos-x64 target: x86_64-apple-darwin diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 17f847ff..246c872a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -75,7 +75,7 @@ jobs: - os: macos-15 name: macos-arm64 target: aarch64-apple-darwin - build_command: npm run build:web && cd src/apps/desktop && npm exec -- tauri build --target aarch64-apple-darwin --bundles dmg + build_command: cd src/apps/desktop && npm exec -- tauri build --target aarch64-apple-darwin --bundles dmg - os: macos-15-intel name: macos-x64 target: x86_64-apple-darwin diff --git a/BitFun-Installer/README.md b/BitFun-Installer/README.md index 1bc936b1..78f41e6c 100644 --- a/BitFun-Installer/README.md +++ b/BitFun-Installer/README.md @@ -78,10 +78,20 @@ Language Select → Options → Progress → Model Setup → Theme Setup ### Setup ```bash +cd .. +npm ci cd BitFun-Installer -npm install +npm ci ``` +Or from repository root: + +```bash +npm --prefix BitFun-Installer ci +``` + +Production installer builds call workspace desktop build scripts, so root dependencies are required. + ### Repository Hygiene Keep generated artifacts out of commits. This project ignores: @@ -107,6 +117,7 @@ Key behavior: - Windows uninstall registry entry points to: `"\\uninstall.exe" --uninstall ""`. - Launching with `--uninstall` opens the dedicated uninstall UI flow. +- Launching `uninstall.exe` directly also enters uninstall mode automatically. Local debug command: @@ -123,24 +134,47 @@ Core implementation: ### Build -Build the complete installer (builds main app first): +Build the complete installer in release mode (default, optimized): + +```bash +npm run installer:build +``` + +Use this as the release entrypoint. `npm run tauri:build` does not prepare validated payload assets for production. +Release artifacts embed payload files into the installer binary, so runtime installation does not depend on an external `payload` folder. + +Build the complete installer in fast mode (faster compile, less optimization): ```bash -node scripts/build-installer.cjs +npm run installer:build:fast ``` Build installer only (skip main app build): ```bash -node scripts/build-installer.cjs --skip-app-build +npm run installer:build:only +``` + +`installer:build:only` now requires an existing valid desktop executable in target output paths. If payload validation fails, build exits with an error. + +Build installer only with fast mode: + +```bash +npm run installer:build:only:fast ``` ### Output -The built installer will be at: +The built executable will be at: + +``` +src-tauri/target/release/bitfun-installer.exe +``` + +Fast mode output path: ``` -src-tauri/target/release/bundle/nsis/BitFun-Installer_x.x.x_x64-setup.exe +src-tauri/target/release-fast/bitfun-installer.exe ``` ## Customization Guide @@ -166,6 +200,7 @@ Edit `src/styles/variables.css` — all colors, spacing, and animations are cont ### Adding Installer Payload Place the built BitFun application files in `src-tauri/payload/` before building the installer. The build script handles this automatically. +During `cargo build`, the payload directory is packed into an embedded zip inside `bitfun-installer.exe`. ## Integration with CI/CD @@ -175,12 +210,12 @@ Add to your GitHub Actions workflow: - name: Build Installer run: | cd BitFun-Installer - npm install - node scripts/build-installer.cjs --skip-app-build + npm ci + npm run installer:build:only - name: Upload Installer uses: actions/upload-artifact@v4 with: - name: BitFun-Setup - path: BitFun-Installer/src-tauri/target/release/bundle/nsis/*.exe + name: BitFun-Installer-Exe + path: BitFun-Installer/src-tauri/target/release/bitfun-installer.exe ``` diff --git a/BitFun-Installer/package-lock.json b/BitFun-Installer/package-lock.json index cb2568ea..7a7dfcbb 100644 --- a/BitFun-Installer/package-lock.json +++ b/BitFun-Installer/package-lock.json @@ -20,6 +20,7 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.6.0", + "terser": "^5.46.0", "typescript": "~5.8.3", "vite": "^7.0.4" } @@ -789,6 +790,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmmirror.com/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1501,6 +1513,19 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -1545,6 +1570,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/caniuse-lite": { "version": "1.0.30001769", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", @@ -1566,6 +1598,13 @@ ], "license": "CC-BY-4.0" }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2002,6 +2041,16 @@ "semver": "bin/semver.js" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2012,6 +2061,36 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmmirror.com/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/BitFun-Installer/package.json b/BitFun-Installer/package.json index aa64e34d..130dfeef 100644 --- a/BitFun-Installer/package.json +++ b/BitFun-Installer/package.json @@ -5,11 +5,22 @@ "type": "module", "description": "BitFun Custom Installer - Modern branded installation experience", "scripts": { - "dev": "vite", - "build": "tsc && vite build", + "sync:model-i18n": "node scripts/sync-model-i18n.cjs", + "sync:theme-i18n": "node scripts/sync-theme-i18n.cjs", + "sync:i18n": "npm run sync:model-i18n && npm run sync:theme-i18n", + "dev": "npm run sync:i18n && vite", + "build": "npm run sync:i18n && tsc && vite build", "preview": "vite preview", - "tauri:dev": "tauri dev", - "tauri:build": "tauri build", + "tauri:dev": "npm run sync:i18n && tauri dev", + "tauri:build": "npm run sync:i18n && tauri build", + "tauri:build:fast": "npm run sync:i18n && tauri build -- --profile release-fast", + "tauri:build:exe": "npm run sync:i18n && tauri build --no-bundle", + "tauri:build:exe:fast": "npm run sync:i18n && tauri build --no-bundle -- --profile release-fast", + "installer:build": "node scripts/build-installer.cjs", + "installer:build:fast": "node scripts/build-installer.cjs --mode fast", + "installer:build:only": "node scripts/build-installer.cjs --skip-app-build", + "installer:build:only:fast": "node scripts/build-installer.cjs --skip-app-build --mode fast", + "installer:dev": "node scripts/build-installer.cjs --dev", "type-check": "tsc --noEmit" }, "dependencies": { @@ -25,6 +36,7 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.6.0", + "terser": "^5.46.0", "typescript": "~5.8.3", "vite": "^7.0.4" } diff --git a/BitFun-Installer/scripts/build-installer.cjs b/BitFun-Installer/scripts/build-installer.cjs index 8ecc9be7..8cbbe317 100644 --- a/BitFun-Installer/scripts/build-installer.cjs +++ b/BitFun-Installer/scripts/build-installer.cjs @@ -1,26 +1,43 @@ /** - * BitFun Installer Build Script + * BitFun Installer build script. * - * This script automates the full installer build process: - * 1. Build the BitFun main application (without bundling) - * 2. Package the app files into a payload archive - * 3. Build the installer Tauri application + * Steps: + * 1. Build BitFun main app (optional). + * 2. Prepare installer payload from built app binaries. + * 3. Build installer app (Tauri). * * Usage: - * node scripts/build-installer.cjs [--skip-app-build] [--dev] + * node scripts/build-installer.cjs [--skip-app-build] [--dev] [--mode fast|release] + * node scripts/build-installer.cjs --fast # same as --mode fast */ -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); +const { execSync } = require("child_process"); +const { createHash } = require("crypto"); +const fs = require("fs"); +const path = require("path"); -const ROOT = path.resolve(__dirname, '..'); -const BITFUN_ROOT = path.resolve(ROOT, '..'); -const PAYLOAD_DIR = path.join(ROOT, 'src-tauri', 'payload'); +const ROOT = path.resolve(__dirname, ".."); +const BITFUN_ROOT = path.resolve(ROOT, ".."); +const PAYLOAD_DIR = path.join(ROOT, "src-tauri", "payload"); -const args = process.argv.slice(2); -const skipAppBuild = args.includes('--skip-app-build'); -const isDev = args.includes('--dev'); +const rawArgs = process.argv.slice(2); +const skipAppBuild = rawArgs.includes("--skip-app-build"); +const isDev = rawArgs.includes("--dev"); +const showHelp = rawArgs.includes("--help") || rawArgs.includes("-h"); +const STRICT_PAYLOAD_VALIDATION = !isDev; +const MIN_APP_EXE_BYTES = 5 * 1024 * 1024; + +function getMode(args) { + if (args.includes("--fast")) return "fast"; + const modeFlagIndex = args.indexOf("--mode"); + if (modeFlagIndex >= 0 && args[modeFlagIndex + 1]) { + return args[modeFlagIndex + 1].trim(); + } + return "release"; +} + +const buildMode = getMode(rawArgs); +const validModes = new Set(["fast", "release"]); function log(msg) { console.log(`\x1b[36m[installer]\x1b[0m ${msg}`); @@ -34,30 +51,160 @@ function error(msg) { function run(cmd, cwd = ROOT) { log(`> ${cmd}`); try { - execSync(cmd, { cwd, stdio: 'inherit' }); - } catch (e) { + execSync(cmd, { cwd, stdio: "inherit" }); + } catch (_e) { error(`Command failed: ${cmd}`); } } -// ── Step 1: Build the main BitFun application ── -if (!skipAppBuild) { - log('Step 1: Building BitFun main application...'); - run('npm run desktop:build:exe', BITFUN_ROOT); +function printHelpAndExit() { + console.log(` +BitFun Installer build script + +Usage: + node scripts/build-installer.cjs [options] + +Options: + --mode Build mode (default: release) + --fast Alias for --mode fast + --skip-app-build Skip building main BitFun app + --dev Run installer with tauri dev instead of tauri build + and allow placeholder payload fallback + --help, -h Show this help +`); + process.exit(0); +} + +function getMainAppBuildCommand(mode) { + if (mode === "fast") { + return "npm run desktop:build:release-fast"; + } + return "npm run desktop:build:exe"; +} + +function getInstallerBuildCommand(mode, devMode) { + if (devMode) return "npm run tauri:dev"; + if (mode === "fast") return "npm run tauri:build:exe:fast"; + return "npm run tauri:build:exe"; +} + +function ensureCleanDir(dir) { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + fs.mkdirSync(dir, { recursive: true }); +} + +function sha256File(filePath) { + const content = fs.readFileSync(filePath); + return createHash("sha256").update(content).digest("hex"); +} + +function writeFileWithManifest(src, dest, manifest, payloadRoot) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + const size = fs.statSync(dest).size; + const rel = path.relative(payloadRoot, dest).replace(/\\/g, "/"); + manifest.files.push({ + path: rel, + size, + sha256: sha256File(dest), + }); +} + +function copyDirRecursiveWithManifest(srcDir, destDir, manifest, payloadRoot) { + fs.mkdirSync(destDir, { recursive: true }); + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + const src = path.join(srcDir, entry.name); + const dest = path.join(destDir, entry.name); + if (entry.isDirectory()) { + copyDirRecursiveWithManifest(src, dest, manifest, payloadRoot); + continue; + } + writeFileWithManifest(src, dest, manifest, payloadRoot); + } +} + +function shouldCopySiblingRuntimeFile(fileName, appExeBaseName) { + if (fileName === appExeBaseName) return false; + if (fileName === ".cargo-lock") return false; + + const lower = fileName.toLowerCase(); + if ( + lower.endsWith(".pdb") || + lower.endsWith(".d") || + lower.endsWith(".exp") || + lower.endsWith(".lib") || + lower.endsWith(".ilk") + ) { + return false; + } + + return true; +} + +function getCandidateAppExePaths(mode) { + const preferredProfiles = + mode === "fast" + ? ["release-fast", "release", "debug"] + : ["release", "release-fast", "debug"]; + + const candidates = []; + for (const profile of preferredProfiles) { + candidates.push( + path.join( + BITFUN_ROOT, + "src", + "apps", + "desktop", + "target", + profile, + "bitfun-desktop.exe" + ), + path.join( + BITFUN_ROOT, + "src", + "apps", + "desktop", + "target", + profile, + "BitFun.exe" + ), + path.join(BITFUN_ROOT, "target", profile, "bitfun-desktop.exe"), + path.join(BITFUN_ROOT, "target", profile, "BitFun.exe") + ); + } + + return candidates; +} + +if (showHelp) { + printHelpAndExit(); +} + +if (!validModes.has(buildMode)) { + error(`Invalid mode "${buildMode}". Supported: fast, release`); +} + +log(`Build mode: ${buildMode}`); +if (isDev) { + log("Installer run mode: dev"); } else { - log('Step 1: Skipped (--skip-app-build)'); + log("Installer run mode: release (strict payload validation)"); } -// ── Step 2: Prepare payload ── -log('Step 2: Preparing installer payload...'); +// Step 1: Build main BitFun app. +if (!skipAppBuild) { + log("Step 1: Building BitFun main application..."); + run(getMainAppBuildCommand(buildMode), BITFUN_ROOT); +} else { + log("Step 1: Skipped (--skip-app-build)"); +} -// Locate the built application -const possiblePaths = [ - path.join(BITFUN_ROOT, 'src', 'apps', 'desktop', 'target', 'release', 'bitfun-desktop.exe'), - path.join(BITFUN_ROOT, 'src', 'apps', 'desktop', 'target', 'release', 'BitFun.exe'), - path.join(BITFUN_ROOT, 'target', 'release', 'bitfun-desktop.exe'), -]; +// Step 2: Prepare payload. +log("Step 2: Preparing installer payload..."); +const possiblePaths = getCandidateAppExePaths(buildMode); let appExePath = null; for (const p of possiblePaths) { if (fs.existsSync(p)) { @@ -66,46 +213,98 @@ for (const p of possiblePaths) { } } -if (!appExePath && !skipAppBuild) { - error('Could not find built BitFun executable. Check the build output.'); +if (!appExePath && STRICT_PAYLOAD_VALIDATION) { + error( + "Could not find built BitFun executable for payload. Build the desktop app first or run with --dev for local debug." + ); } if (appExePath) { - // Create payload directory - if (fs.existsSync(PAYLOAD_DIR)) { - fs.rmSync(PAYLOAD_DIR, { recursive: true }); - } - fs.mkdirSync(PAYLOAD_DIR, { recursive: true }); + ensureCleanDir(PAYLOAD_DIR); - // Copy the executable - const destExe = path.join(PAYLOAD_DIR, 'BitFun.exe'); - fs.copyFileSync(appExePath, destExe); + const manifest = { + generatedAt: new Date().toISOString(), + mode: buildMode, + sourceExe: appExePath, + files: [], + }; + + const destExe = path.join(PAYLOAD_DIR, "BitFun.exe"); + writeFileWithManifest(appExePath, destExe, manifest, PAYLOAD_DIR); log(`Copied: ${appExePath} -> ${destExe}`); - // Copy WebView2 resources and other runtime files if they exist + const exeSize = fs.statSync(destExe).size; + if (STRICT_PAYLOAD_VALIDATION && exeSize < MIN_APP_EXE_BYTES) { + error( + `BitFun.exe in payload is unexpectedly small (${exeSize} bytes). Refusing to continue.` + ); + } + const releaseDir = path.dirname(appExePath); - const runtimeFiles = fs.readdirSync(releaseDir).filter((f) => { - return f.endsWith('.dll') || f === 'WebView2Loader.dll'; - }); - for (const file of runtimeFiles) { + const appExeBaseName = path.basename(appExePath); + const siblingFiles = fs + .readdirSync(releaseDir, { withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => e.name) + .filter((file) => shouldCopySiblingRuntimeFile(file, appExeBaseName)); + + for (const file of siblingFiles) { const src = path.join(releaseDir, file); const dest = path.join(PAYLOAD_DIR, file); - fs.copyFileSync(src, dest); - log(`Copied runtime: ${file}`); + writeFileWithManifest(src, dest, manifest, PAYLOAD_DIR); + log(`Copied runtime file: ${file}`); + } + + const runtimeDirs = ["resources", "locales", "swiftshader"]; + for (const dirName of runtimeDirs) { + const srcDir = path.join(releaseDir, dirName); + if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) { + continue; + } + const destDir = path.join(PAYLOAD_DIR, dirName); + copyDirRecursiveWithManifest(srcDir, destDir, manifest, PAYLOAD_DIR); + log(`Copied runtime directory: ${dirName}`); + } + + const manifestPath = path.join(PAYLOAD_DIR, "payload-manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + log(`Wrote payload manifest: ${manifestPath}`); + + if (STRICT_PAYLOAD_VALIDATION && manifest.files.length === 0) { + error("Payload manifest has no files. Refusing to build installer."); } } else { - log('No app executable found. Payload directory will be empty (dev mode).'); - fs.mkdirSync(PAYLOAD_DIR, { recursive: true }); + log("No app executable found. Payload directory will be empty (dev-only fallback)."); + ensureCleanDir(PAYLOAD_DIR); } -// ── Step 3: Build the installer ── -log('Step 3: Building installer...'); +// Step 3: Build installer. +log("Step 3: Building installer..."); +run(getInstallerBuildCommand(buildMode, isDev)); +const installerTargetProfile = isDev + ? "debug" + : buildMode === "fast" + ? "release-fast" + : "release"; +log("Installer build complete."); if (isDev) { - run('npm run tauri:dev'); + log( + `Output directory: ${path.join( + ROOT, + "src-tauri", + "target", + installerTargetProfile + )}` + ); } else { - run('npm run tauri:build'); + log( + `Output: ${path.join( + ROOT, + "src-tauri", + "target", + installerTargetProfile, + "bitfun-installer.exe" + )}` + ); } - -log('✓ Installer build complete!'); -log(`Output: ${path.join(ROOT, 'src-tauri', 'target', 'release', 'bundle')}`); diff --git a/BitFun-Installer/scripts/sync-model-i18n.cjs b/BitFun-Installer/scripts/sync-model-i18n.cjs new file mode 100644 index 00000000..b25583f9 --- /dev/null +++ b/BitFun-Installer/scripts/sync-model-i18n.cjs @@ -0,0 +1,161 @@ +const fs = require('fs'); +const path = require('path'); + +const INSTALLER_ROOT = path.resolve(__dirname, '..'); +const PROJECT_ROOT = path.resolve(INSTALLER_ROOT, '..'); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, data) { + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); +} + +function get(obj, keyPath, fallback) { + const segments = keyPath.split('.'); + let current = obj; + for (const seg of segments) { + if (!current || typeof current !== 'object' || !(seg in current)) { + return fallback; + } + current = current[seg]; + } + return current ?? fallback; +} + +function mergeDeep(target, source) { + const result = { ...(target || {}) }; + for (const [key, value] of Object.entries(source || {})) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + result[key] = mergeDeep(result[key], value); + } else { + result[key] = value; + } + } + return result; +} + +function buildProviderPatch(settingsAiModel) { + const providers = get(settingsAiModel, 'providers', {}); + const providerPatch = {}; + + for (const [providerId, provider] of Object.entries(providers)) { + providerPatch[providerId] = { + name: get(provider, 'name', providerId), + description: get(provider, 'description', ''), + }; + + if (provider && provider.urlOptions && typeof provider.urlOptions === 'object') { + providerPatch[providerId].urlOptions = { ...provider.urlOptions }; + } + } + + return providerPatch; +} + +function buildModelPatch(onboarding, settingsAiModel, languageTag) { + const isZh = languageTag === 'zh'; + return { + description: get( + onboarding, + 'model.description', + 'Configure AI model provider, API key, and advanced parameters.' + ), + providerLabel: get(onboarding, 'model.provider.label', 'Model Provider'), + selectProvider: get(onboarding, 'model.provider.placeholder', 'Select a provider...'), + customProvider: get(onboarding, 'model.provider.options.custom', 'Custom'), + getApiKey: get(onboarding, 'model.apiKey.help', 'How to get an API Key?'), + modelNamePlaceholder: get( + onboarding, + 'model.modelName.inputPlaceholder', + get(onboarding, 'model.modelName.placeholder', 'Enter model name...') + ), + modelNameSelectPlaceholder: get(onboarding, 'model.modelName.selectPlaceholder', 'Select a model...'), + modelSearchPlaceholder: get( + onboarding, + 'model.modelName.searchPlaceholder', + 'Search or enter a custom model name...' + ), + modelNoResults: isZh ? '没有匹配的模型' : 'No matching models', + customModel: get(onboarding, 'model.modelName.customHint', 'Use custom model name'), + baseUrlPlaceholder: get(onboarding, 'model.baseUrl.placeholder', 'Enter API URL'), + customRequestBodyPlaceholder: get( + onboarding, + 'model.advanced.customRequestBodyPlaceholder', + '{\n "temperature": 0.8,\n "top_p": 0.9\n}' + ), + jsonValid: get(onboarding, 'model.advanced.jsonValid', 'Valid JSON format'), + jsonInvalid: get(onboarding, 'model.advanced.jsonInvalid', 'Invalid JSON format'), + skipSslVerify: get( + settingsAiModel, + 'advancedSettings.skipSslVerify.label', + 'Skip SSL Certificate Verification' + ), + customHeadersModeMerge: get( + settingsAiModel, + 'advancedSettings.customHeaders.modeMerge', + 'Merge Override' + ), + customHeadersModeReplace: get( + settingsAiModel, + 'advancedSettings.customHeaders.modeReplace', + 'Replace All' + ), + addHeader: get(settingsAiModel, 'advancedSettings.customHeaders.addHeader', 'Add Field'), + headerKey: get(settingsAiModel, 'advancedSettings.customHeaders.keyPlaceholder', 'key'), + headerValue: get(settingsAiModel, 'advancedSettings.customHeaders.valuePlaceholder', 'value'), + testConnection: get(onboarding, 'model.testConnection', 'Test Connection'), + testing: get(onboarding, 'model.testing', 'Testing...'), + testSuccess: get(onboarding, 'model.testSuccess', 'Connection successful'), + testFailed: get(onboarding, 'model.testFailed', 'Connection failed'), + advancedShow: 'Show advanced settings', + advancedHide: 'Hide advanced settings', + providers: buildProviderPatch(settingsAiModel), + }; +} + +function syncOne(languageTag) { + const localeDir = languageTag === 'zh' ? 'zh-CN' : 'en-US'; + const installerLocale = languageTag === 'zh' ? 'zh.json' : 'en.json'; + + const sourceOnboardingPath = path.join( + PROJECT_ROOT, + 'src', + 'web-ui', + 'src', + 'locales', + localeDir, + 'onboarding.json' + ); + + const sourceAiModelPath = path.join( + PROJECT_ROOT, + 'src', + 'web-ui', + 'src', + 'locales', + localeDir, + 'settings', + 'ai-model.json' + ); + + const targetPath = path.join(INSTALLER_ROOT, 'src', 'i18n', 'locales', installerLocale); + + const onboarding = readJson(sourceOnboardingPath); + const settingsAiModel = readJson(sourceAiModelPath); + const target = readJson(targetPath); + + const patch = buildModelPatch(onboarding, settingsAiModel, languageTag); + target.model = mergeDeep(target.model || {}, patch); + + writeJson(targetPath, target); +} + +function main() { + syncOne('en'); + syncOne('zh'); + console.log('[sync-model-i18n] Synced installer model i18n from web-ui locales.'); +} + +main(); diff --git a/BitFun-Installer/scripts/sync-theme-i18n.cjs b/BitFun-Installer/scripts/sync-theme-i18n.cjs new file mode 100644 index 00000000..f3648e0b --- /dev/null +++ b/BitFun-Installer/scripts/sync-theme-i18n.cjs @@ -0,0 +1,106 @@ +const fs = require("fs"); +const path = require("path"); + +const INSTALLER_ROOT = path.resolve(__dirname, ".."); +const PROJECT_ROOT = path.resolve(INSTALLER_ROOT, ".."); + +const THEME_IDS = [ + "bitfun-dark", + "bitfun-light", + "bitfun-midnight", + "bitfun-china-style", + "bitfun-china-night", + "bitfun-cyber", + "bitfun-slate", +]; + +function readJson(filePath) { + const content = fs.readFileSync(filePath, "utf8"); + return JSON.parse(content); +} + +function writeJson(filePath, data) { + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); +} + +function extractThemeNames(source, sourceLabel) { + const presets = source?.theme?.presets; + if (!presets || typeof presets !== "object") { + throw new Error(`Invalid theme presets in ${sourceLabel}`); + } + + const result = {}; + for (const themeId of THEME_IDS) { + const name = presets?.[themeId]?.name; + if (typeof name !== "string" || name.trim() === "") { + throw new Error(`Missing theme name for '${themeId}' in ${sourceLabel}`); + } + result[themeId] = name; + } + + return result; +} + +function injectThemeNames(target, themeNames) { + if (!target.themeSetup || typeof target.themeSetup !== "object") { + target.themeSetup = {}; + } + target.themeSetup.themeNames = { + ...(target.themeSetup.themeNames || {}), + ...themeNames, + }; + return target; +} + +function main() { + const sourceEnPath = path.join( + PROJECT_ROOT, + "src", + "web-ui", + "src", + "locales", + "en-US", + "settings", + "theme.json" + ); + const sourceZhPath = path.join( + PROJECT_ROOT, + "src", + "web-ui", + "src", + "locales", + "zh-CN", + "settings", + "theme.json" + ); + + const targetEnPath = path.join( + INSTALLER_ROOT, + "src", + "i18n", + "locales", + "en.json" + ); + const targetZhPath = path.join( + INSTALLER_ROOT, + "src", + "i18n", + "locales", + "zh.json" + ); + + const sourceEn = readJson(sourceEnPath); + const sourceZh = readJson(sourceZhPath); + const targetEn = readJson(targetEnPath); + const targetZh = readJson(targetZhPath); + + const enThemeNames = extractThemeNames(sourceEn, sourceEnPath); + const zhThemeNames = extractThemeNames(sourceZh, sourceZhPath); + + writeJson(targetEnPath, injectThemeNames(targetEn, enThemeNames)); + writeJson(targetZhPath, injectThemeNames(targetZh, zhThemeNames)); + + console.log("[sync-theme-i18n] Synced installer theme names from web-ui locales."); +} + +main(); diff --git a/BitFun-Installer/src-tauri/Cargo.toml b/BitFun-Installer/src-tauri/Cargo.toml index 908a9cb5..7476787b 100644 --- a/BitFun-Installer/src-tauri/Cargo.toml +++ b/BitFun-Installer/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [build-dependencies] tauri-build = { version = "2", features = [] } +zip = "0.6" [dependencies] tauri = { version = "2", features = [] } @@ -29,6 +30,7 @@ zip = "0.6" flate2 = "1.0" tar = "0.4" chrono = "0.4" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } [target.'cfg(windows)'.dependencies] winreg = "0.52" @@ -39,3 +41,10 @@ opt-level = "z" lto = true codegen-units = 1 strip = true + +[profile.release-fast] +inherits = "release" +lto = false +codegen-units = 16 +strip = false +incremental = true diff --git a/BitFun-Installer/src-tauri/build.rs b/BitFun-Installer/src-tauri/build.rs index d860e1e6..a31bedf4 100644 --- a/BitFun-Installer/src-tauri/build.rs +++ b/BitFun-Installer/src-tauri/build.rs @@ -1,3 +1,104 @@ +use std::fs; +use std::fs::File; +use std::io::{self, Read, Seek, Write}; +use std::path::{Path, PathBuf}; +use zip::write::FileOptions; +use zip::{CompressionMethod, ZipWriter}; + fn main() { + if let Err(err) = build_embedded_payload() { + panic!("failed to build embedded payload: {err}"); + } + tauri_build::build() } + +fn build_embedded_payload() -> Result<(), Box> { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?); + let payload_dir = manifest_dir.join("payload"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR")?); + let out_zip = out_dir.join("embedded_payload.zip"); + + println!("cargo:rerun-if-changed={}", payload_dir.display()); + + let mut file_count = 0usize; + if payload_dir.exists() && payload_dir.is_dir() { + file_count = create_payload_zip(&payload_dir, &out_zip)?; + emit_rerun_for_files(&payload_dir)?; + } else { + create_empty_zip(&out_zip)?; + } + + let available = if file_count > 0 { "1" } else { "0" }; + println!("cargo:rustc-env=EMBEDDED_PAYLOAD_AVAILABLE={available}"); + println!("cargo:warning=embedded payload files: {file_count}"); + + Ok(()) +} + +fn emit_rerun_for_files(dir: &Path) -> io::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + println!("cargo:rerun-if-changed={}", path.display()); + if path.is_dir() { + emit_rerun_for_files(&path)?; + } + } + Ok(()) +} + +fn create_empty_zip(out_zip: &Path) -> zip::result::ZipResult<()> { + let file = File::create(out_zip)?; + let mut zip = ZipWriter::new(file); + zip.finish()?; + Ok(()) +} + +fn create_payload_zip(payload_dir: &Path, out_zip: &Path) -> zip::result::ZipResult { + let file = File::create(out_zip)?; + let mut zip = ZipWriter::new(file); + let options = FileOptions::default().compression_method(CompressionMethod::Deflated); + + let mut file_count = 0usize; + add_dir_to_zip(&mut zip, payload_dir, payload_dir, options, &mut file_count)?; + + zip.finish()?; + Ok(file_count) +} + +fn add_dir_to_zip( + zip: &mut ZipWriter, + root: &Path, + current: &Path, + options: FileOptions, + file_count: &mut usize, +) -> zip::result::ZipResult<()> { + let mut entries = fs::read_dir(current)? + .collect::, _>>() + .map_err(zip::result::ZipError::Io)?; + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + let rel = path + .strip_prefix(root) + .map_err(|_| zip::result::ZipError::FileNotFound)?; + let rel_name = rel.to_string_lossy().replace('\\', "/"); + + if path.is_dir() { + zip.add_directory(format!("{rel_name}/"), options)?; + add_dir_to_zip(zip, root, &path, options, file_count)?; + continue; + } + + zip.start_file(rel_name, options)?; + let mut src = File::open(&path)?; + let mut buf = Vec::new(); + src.read_to_end(&mut buf)?; + zip.write_all(&buf)?; + *file_count += 1; + } + + Ok(()) +} diff --git a/BitFun-Installer/src-tauri/src/installer/commands.rs b/BitFun-Installer/src-tauri/src/installer/commands.rs index 1f03e155..1bc99a0c 100644 --- a/BitFun-Installer/src-tauri/src/installer/commands.rs +++ b/BitFun-Installer/src-tauri/src/installer/commands.rs @@ -1,11 +1,15 @@ //! Tauri commands exposed to the frontend installer UI. use super::extract::{self, ESTIMATED_INSTALL_SIZE}; -use super::types::{DiskSpaceInfo, InstallOptions, InstallProgress, ModelConfig}; +use super::types::{ConnectionTestResult, DiskSpaceInfo, InstallOptions, InstallProgress, ModelConfig}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use serde::Serialize; use serde_json::{Map, Value}; +use std::fs::File; +use std::io::Cursor; use std::path::{Path, PathBuf}; -use tauri::{Emitter, Window}; +use std::time::Duration; +use tauri::{Emitter, Manager, Window}; #[cfg(target_os = "windows")] #[derive(Default)] @@ -17,11 +21,17 @@ struct WindowsInstallState { added_to_path: bool, } +const MIN_WINDOWS_APP_EXE_BYTES: u64 = 5 * 1024 * 1024; +const PAYLOAD_MANIFEST_FILE: &str = "payload-manifest.json"; +const EMBEDDED_PAYLOAD_ZIP: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/embedded_payload.zip")); + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LaunchContext { pub mode: String, pub uninstall_path: Option, + pub app_language: Option, } /// Get the default installation path. @@ -120,6 +130,7 @@ unsafe fn windows_sys_get_disk_free_space( #[tauri::command] pub fn get_launch_context() -> LaunchContext { let args: Vec = std::env::args().collect(); + let app_language = read_saved_app_language(); if let Some(idx) = args.iter().position(|arg| arg == "--uninstall") { let uninstall_path = args .get(idx + 1) @@ -128,12 +139,22 @@ pub fn get_launch_context() -> LaunchContext { return LaunchContext { mode: "uninstall".to_string(), uninstall_path, + app_language, + }; + } + + if is_running_as_uninstall_binary() { + return LaunchContext { + mode: "uninstall".to_string(), + uninstall_path: guess_uninstall_path_from_exe(), + app_language, }; } LaunchContext { mode: "install".to_string(), uninstall_path: None, + app_language, } } @@ -177,10 +198,7 @@ pub fn validate_install_path(path: String) -> Result { /// Main installation command. Emits progress events to the frontend. #[tauri::command] -pub async fn start_installation( - window: Window, - options: InstallOptions, -) -> Result<(), String> { +pub async fn start_installation(window: Window, options: InstallOptions) -> Result<(), String> { let install_path = PathBuf::from(&options.install_path); let install_dir_was_absent = !install_path.exists(); #[cfg(target_os = "windows")] @@ -195,33 +213,88 @@ pub async fn start_installation( // Step 2: Extract / copy application files emit_progress(&window, "extract", 15, "Extracting application files..."); - // In production, this would extract from an embedded archive. - // For development, we look for a payload directory next to the installer. + let mut extracted = false; + let mut used_debug_placeholder = false; + let mut checked_locations: Vec = Vec::new(); + + if embedded_payload_available() { + checked_locations.push("embedded payload zip".to_string()); + preflight_validate_payload_zip_bytes(EMBEDDED_PAYLOAD_ZIP, "embedded payload zip")?; + extract::extract_zip_bytes_with_filter( + EMBEDDED_PAYLOAD_ZIP, + &install_path, + should_install_payload_path, + ) + .map_err(|e| format!("Embedded payload extraction failed: {}", e))?; + extracted = true; + log::info!("Extracted payload from embedded installer archive"); + } + + // Fallback to external payload locations for compatibility and local debug. let exe_dir = std::env::current_exe() .map_err(|e| e.to_string())? .parent() .unwrap_or_else(|| Path::new(".")) .to_path_buf(); - let payload_zip = exe_dir.join("payload.zip"); - let payload_dir = exe_dir.join("payload"); + if !extracted { + for candidate in build_payload_candidates(&window, &exe_dir) { + if candidate.is_zip { + checked_locations.push(format!("zip: {}", candidate.path.display())); + if !candidate.path.exists() { + continue; + } + preflight_validate_payload_zip_file(&candidate.path, &candidate.label)?; + extract::extract_zip_with_filter( + &candidate.path, + &install_path, + should_install_payload_path, + ) + .map_err(|e| format!("Extraction failed from {}: {}", candidate.label, e))?; + extracted = true; + log::info!("Extracted payload from {}", candidate.label); + break; + } + + checked_locations.push(format!("dir: {}", candidate.path.display())); + if !candidate.path.exists() { + continue; + } + preflight_validate_payload_dir(&candidate.path, &candidate.label)?; + extract::copy_directory_with_filter( + &candidate.path, + &install_path, + should_install_payload_path, + ) + .map_err(|e| format!("File copy failed from {}: {}", candidate.label, e))?; + extracted = true; + log::info!("Copied payload from {}", candidate.label); + break; + } + } - if payload_zip.exists() { - extract::extract_zip(&payload_zip, &install_path) - .map_err(|e| format!("Extraction failed: {}", e))?; - } else if payload_dir.exists() { - extract::copy_directory(&payload_dir, &install_path) - .map_err(|e| format!("File copy failed: {}", e))?; - } else { - // Development mode: create a placeholder - log::warn!("No payload found - running in development mode"); - let placeholder = install_path.join("BitFun.exe"); - if !placeholder.exists() { - std::fs::write(&placeholder, "placeholder") - .map_err(|e| format!("Failed to write placeholder: {}", e))?; + if !extracted { + if cfg!(debug_assertions) { + // Development mode: create a placeholder to simplify local UI iteration. + log::warn!("No payload found - running in development mode"); + let placeholder = install_path.join("BitFun.exe"); + if !placeholder.exists() { + std::fs::write(&placeholder, "placeholder") + .map_err(|e| format!("Failed to write placeholder: {}", e))?; + } + used_debug_placeholder = true; + } else { + return Err(format!( + "Installer payload is missing. Checked: {}", + checked_locations.join(" | ") + )); } } + if !used_debug_placeholder { + verify_installed_payload(&install_path)?; + } + emit_progress(&window, "extract", 50, "Files extracted successfully"); // Step 3: Windows-specific operations @@ -241,8 +314,12 @@ pub async fn start_installation( ); emit_progress(&window, "registry", 60, "Registering application..."); - registry::register_uninstall_entry(&install_path, "0.1.0", &uninstall_command) - .map_err(|e| format!("Registry error: {}", e))?; + registry::register_uninstall_entry( + &install_path, + env!("CARGO_PKG_VERSION"), + &uninstall_command, + ) + .map_err(|e| format!("Registry error: {}", e))?; windows_state.uninstall_registered = true; // Desktop shortcut @@ -263,7 +340,12 @@ pub async fn start_installation( // Context menu if options.context_menu { - emit_progress(&window, "context_menu", 80, "Adding context menu integration..."); + emit_progress( + &window, + "context_menu", + 80, + "Adding context menu integration...", + ); registry::register_context_menu(&install_path) .map_err(|e| format!("Context menu error: {}", e))?; windows_state.context_menu_registered = true; @@ -272,8 +354,7 @@ pub async fn start_installation( // PATH if options.add_to_path { emit_progress(&window, "path", 85, "Adding to system PATH..."); - registry::add_to_path(&install_path) - .map_err(|e| format!("PATH error: {}", e))?; + registry::add_to_path(&install_path).map_err(|e| format!("PATH error: {}", e))?; windows_state.added_to_path = true; } } @@ -318,21 +399,39 @@ pub async fn uninstall(install_path: String) -> Result<(), String> { #[cfg(target_os = "windows")] { let current_exe = std::env::current_exe().ok(); - let running_from_install_dir = current_exe + let running_uninstall_binary = current_exe .as_ref() - .map(|exe| exe.starts_with(&install_path)) + .and_then(|exe| exe.file_stem().map(|s| s.to_string_lossy().to_string())) + .map(|stem| stem.eq_ignore_ascii_case("uninstall")) .unwrap_or(false); - if running_from_install_dir { + let current_exe_parent = current_exe + .as_ref() + .and_then(|exe| exe.parent().map(|p| p.to_path_buf())); + let running_from_install_dir = current_exe_parent + .as_ref() + .map(|parent| windows_path_eq_case_insensitive(parent, &install_path)) + .unwrap_or(false); + + append_uninstall_runtime_log(&format!( + "uninstall called: install_path='{}', current_exe='{}', running_uninstall_binary={}, running_from_install_dir={}", + install_path.display(), + current_exe + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()), + running_uninstall_binary, + running_from_install_dir + )); + + if running_uninstall_binary || running_from_install_dir { if install_path.exists() { - let cmd = format!( - "ping 127.0.0.1 -n 3 > nul && rmdir /s /q \"{}\"", + schedule_windows_self_uninstall_cleanup(&install_path)?; + } else { + append_uninstall_runtime_log(&format!( + "install path does not exist, skip cleanup schedule: {}", install_path.display() - ); - std::process::Command::new("cmd") - .args(["/C", &cmd]) - .spawn() - .map_err(|e| format!("Failed to schedule uninstall cleanup: {}", e))?; + )); } return Ok(()); } @@ -346,6 +445,102 @@ pub async fn uninstall(install_path: String) -> Result<(), String> { Ok(()) } +#[cfg(target_os = "windows")] +fn schedule_windows_self_uninstall_cleanup(install_path: &Path) -> Result<(), String> { + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let temp_dir = std::env::temp_dir(); + let pid = std::process::id(); + let script_path = temp_dir.join(format!("bitfun-uninstall-{}.cmd", pid)); + let log_path = temp_dir.join(format!("bitfun-uninstall-cleanup-{}.log", pid)); + + let script = format!( + r#"@echo off +setlocal enableextensions +set "TARGET=%~1" +set "LOG=%~2" +if "%TARGET%"=="" exit /b 2 +if "%LOG%"=="" set "LOG=%TEMP%\bitfun-uninstall-cleanup.log" +echo [%DATE% %TIME%] cleanup start > "%LOG%" +cd /d "%TEMP%" +taskkill /f /im BitFun.exe >> "%LOG%" 2>&1 +set "DONE=0" +for /L %%i in (1,1,30) do ( + rmdir /s /q "%TARGET%" >> "%LOG%" 2>&1 + if not exist "%TARGET%" ( + echo [%DATE% %TIME%] cleanup success on try %%i >> "%LOG%" + set "DONE=1" + goto :cleanup_done + ) + timeout /t 1 /nobreak >nul +) +:cleanup_done +if "%DONE%"=="1" exit /b 0 +echo [%DATE% %TIME%] cleanup failed after retries >> "%LOG%" +exit /b 1 +"# + ); + + std::fs::write(&script_path, script) + .map_err(|e| format!("Failed to write cleanup script: {}", e))?; + + append_uninstall_runtime_log(&format!( + "scheduled cleanup script='{}', target='{}', cleanup_log='{}'", + script_path.display(), + install_path.display(), + log_path.display() + )); + + let child = std::process::Command::new("cmd") + .arg("/C") + .arg("call") + .arg(&script_path) + .arg(install_path) + .arg(&log_path) + .current_dir(&temp_dir) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| format!("Failed to schedule uninstall cleanup: {}", e))?; + + append_uninstall_runtime_log(&format!( + "cleanup process spawned: pid={}", + child.id() + )); + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn windows_path_eq_case_insensitive(a: &Path, b: &Path) -> bool { + fn normalize(path: &Path) -> String { + let mut s = path.to_string_lossy().replace('/', "\\").to_lowercase(); + while s.ends_with('\\') { + s.pop(); + } + s + } + normalize(a) == normalize(b) +} + +#[cfg(target_os = "windows")] +fn append_uninstall_runtime_log(message: &str) { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let log_path = std::env::temp_dir().join("bitfun-uninstall-runtime.log"); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + use std::io::Write; + let _ = writeln!(file, "[{}] {}", ts, message); + } +} + /// Launch the installed application. #[tauri::command] pub fn launch_application(install_path: String) -> Result<(), String> { @@ -410,8 +605,241 @@ pub fn set_model_config(model_config: ModelConfig) -> Result<(), String> { apply_first_launch_model(&model_config) } +/// Validate model configuration connectivity from installer. +#[tauri::command] +pub async fn test_model_config_connection(model_config: ModelConfig) -> Result { + let started_at = std::time::Instant::now(); + + let required_fields = [ + ("baseUrl", model_config.base_url.trim()), + ("apiKey", model_config.api_key.trim()), + ("modelName", model_config.model_name.trim()), + ]; + for (field, value) in required_fields { + if value.is_empty() { + return Ok(ConnectionTestResult { + success: false, + response_time_ms: started_at.elapsed().as_millis() as u64, + model_response: None, + error_details: Some(format!("Missing required field: {}", field)), + }); + } + } + + let test_result = run_model_connection_test(&model_config).await; + let elapsed_ms = started_at.elapsed().as_millis() as u64; + + match test_result { + Ok(model_response) => Ok(ConnectionTestResult { + success: true, + response_time_ms: elapsed_ms, + model_response, + error_details: None, + }), + Err(error_details) => Ok(ConnectionTestResult { + success: false, + response_time_ms: elapsed_ms, + model_response: None, + error_details: Some(error_details), + }), + } +} + // ── Helpers ──────────────────────────────────────────────────────────────── +fn normalize_api_format(model: &ModelConfig) -> String { + let normalized = model.format.trim().to_ascii_lowercase(); + if normalized == "anthropic" { + "anthropic".to_string() + } else { + "openai".to_string() + } +} + +fn append_endpoint(base_url: &str, endpoint: &str) -> String { + let base = base_url.trim(); + if base.is_empty() { + return endpoint.to_string(); + } + if base.ends_with(endpoint) { + return base.to_string(); + } + format!("{}/{}", base.trim_end_matches('/'), endpoint) +} + +fn resolve_request_url(base_url: &str, format: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/').to_string(); + if trimmed.is_empty() { + return String::new(); + } + + if let Some(stripped) = trimmed.strip_suffix('#') { + return stripped.trim_end_matches('/').to_string(); + } + + match format { + "anthropic" => append_endpoint(&trimmed, "v1/messages"), + "openai" => append_endpoint(&trimmed, "chat/completions"), + _ => trimmed, + } +} + +fn parse_custom_request_body(raw: &Option) -> Result>, String> { + let Some(raw_value) = raw else { + return Ok(None); + }; + + let trimmed = raw_value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + let parsed: Value = + serde_json::from_str(trimmed).map_err(|e| format!("customRequestBody is invalid JSON: {}", e))?; + let obj = parsed.as_object().ok_or_else(|| { + "customRequestBody must be a JSON object (for example: {\"temperature\": 0.7})".to_string() + })?; + Ok(Some(obj.clone())) +} + +fn merge_json_object(target: &mut Map, source: &Map) { + for (key, value) in source { + target.insert(key.clone(), value.clone()); + } +} + +fn build_request_headers(model: &ModelConfig, format: &str) -> Result { + let mode = model + .custom_headers_mode + .as_deref() + .unwrap_or("merge") + .trim() + .to_ascii_lowercase(); + if mode != "merge" && mode != "replace" { + return Err("customHeadersMode must be 'merge' or 'replace'".to_string()); + } + + let mut headers = HeaderMap::new(); + if mode != "replace" { + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + if format == "anthropic" { + let api_key = HeaderValue::from_str(model.api_key.trim()) + .map_err(|_| "apiKey contains unsupported header characters".to_string())?; + headers.insert(HeaderName::from_static("x-api-key"), api_key); + headers.insert( + HeaderName::from_static("anthropic-version"), + HeaderValue::from_static("2023-06-01"), + ); + } else { + let bearer = format!("Bearer {}", model.api_key.trim()); + let auth = HeaderValue::from_str(&bearer) + .map_err(|_| "apiKey contains unsupported header characters".to_string())?; + headers.insert(AUTHORIZATION, auth); + } + } + + if let Some(custom_headers) = &model.custom_headers { + for (key, value) in custom_headers { + let key_trimmed = key.trim(); + if key_trimmed.is_empty() { + continue; + } + let header_name = HeaderName::from_bytes(key_trimmed.as_bytes()) + .map_err(|_| format!("Invalid custom header name: {}", key_trimmed))?; + let header_value = HeaderValue::from_str(value.trim()) + .map_err(|_| format!("Invalid custom header value for '{}'", key_trimmed))?; + headers.insert(header_name, header_value); + } + } + + Ok(headers) +} + +fn truncate_error_text(raw: &str, limit: usize) -> String { + let compact = raw.replace('\n', " ").replace('\r', " ").trim().to_string(); + if compact.chars().count() <= limit { + return compact; + } + compact.chars().take(limit).collect::() + "..." +} + +async fn run_model_connection_test(model: &ModelConfig) -> Result, String> { + let format = normalize_api_format(model); + let endpoint = resolve_request_url(&model.base_url, &format); + let headers = build_request_headers(model, &format)?; + let custom_request_body = parse_custom_request_body(&model.custom_request_body)?; + + let mut payload = Map::new(); + payload.insert("model".to_string(), Value::String(model.model_name.trim().to_string())); + if format == "anthropic" { + payload.insert("max_tokens".to_string(), Value::Number(16_u64.into())); + payload.insert( + "messages".to_string(), + serde_json::json!([{ "role": "user", "content": "hello" }]), + ); + } else { + payload.insert("max_tokens".to_string(), Value::Number(16_u64.into())); + payload.insert("temperature".to_string(), serde_json::json!(0.1)); + payload.insert( + "messages".to_string(), + serde_json::json!([{ "role": "user", "content": "hello" }]), + ); + } + if let Some(extra) = custom_request_body.as_ref() { + merge_json_object(&mut payload, extra); + } + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .danger_accept_invalid_certs(model.skip_ssl_verify.unwrap_or(false)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let response = client + .post(endpoint) + .headers(headers) + .json(&Value::Object(payload)) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let status = response.status(); + let response_body = response + .text() + .await + .map_err(|e| format!("Failed to read response body: {}", e))?; + if !status.is_success() { + return Err(format!( + "HTTP {}: {}", + status.as_u16(), + truncate_error_text(&response_body, 260) + )); + } + + let parsed_json = serde_json::from_str::(&response_body).unwrap_or(Value::Null); + let model_response = if format == "anthropic" { + parsed_json + .get("content") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|item| item.get("text")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + parsed_json + .get("choices") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|item| item.get("message")) + .and_then(|msg| msg.get("content")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }; + + Ok(model_response) +} + fn emit_progress(window: &Window, step: &str, percent: u32, message: &str) { let progress = InstallProgress { step: step.to_string(), @@ -429,6 +857,78 @@ fn guess_uninstall_path_from_exe() -> Option { .map(|p| p.to_string_lossy().to_string()) } +fn is_running_as_uninstall_binary() -> bool { + std::env::current_exe() + .ok() + .and_then(|exe| exe.file_stem().map(|s| s.to_string_lossy().to_string())) + .map(|stem| stem.eq_ignore_ascii_case("uninstall")) + .unwrap_or(false) +} + +fn embedded_payload_available() -> bool { + option_env!("EMBEDDED_PAYLOAD_AVAILABLE") + .map(|v| v == "1") + .unwrap_or(false) +} + +#[derive(Debug)] +struct PayloadCandidate { + label: String, + path: PathBuf, + is_zip: bool, +} + +fn build_payload_candidates(window: &Window, exe_dir: &Path) -> Vec { + let mut candidates = Vec::new(); + + if let Ok(resource_dir) = window.app_handle().path().resource_dir() { + candidates.push(PayloadCandidate { + label: "resource_dir/payload.zip".to_string(), + path: resource_dir.join("payload.zip"), + is_zip: true, + }); + candidates.push(PayloadCandidate { + label: "resource_dir/payload".to_string(), + path: resource_dir.join("payload"), + is_zip: false, + }); + // Some bundle layouts keep runtime resources under a nested resources directory. + candidates.push(PayloadCandidate { + label: "resource_dir/resources/payload.zip".to_string(), + path: resource_dir.join("resources").join("payload.zip"), + is_zip: true, + }); + candidates.push(PayloadCandidate { + label: "resource_dir/resources/payload".to_string(), + path: resource_dir.join("resources").join("payload"), + is_zip: false, + }); + } + + candidates.push(PayloadCandidate { + label: "exe_dir/payload.zip".to_string(), + path: exe_dir.join("payload.zip"), + is_zip: true, + }); + candidates.push(PayloadCandidate { + label: "exe_dir/payload".to_string(), + path: exe_dir.join("payload"), + is_zip: false, + }); + candidates.push(PayloadCandidate { + label: "exe_dir/resources/payload.zip".to_string(), + path: exe_dir.join("resources").join("payload.zip"), + is_zip: true, + }); + candidates.push(PayloadCandidate { + label: "exe_dir/resources/payload".to_string(), + path: exe_dir.join("resources").join("payload"), + is_zip: false, + }); + + candidates +} + fn find_existing_ancestor(path: &Path) -> PathBuf { let mut current = path.to_path_buf(); while !current.exists() { @@ -451,6 +951,25 @@ fn ensure_app_config_path() -> Result { Ok(config_root.join("app.json")) } +fn read_saved_app_language() -> Option { + let app_config_file = ensure_app_config_path().ok()?; + if !app_config_file.exists() { + return None; + } + + let content = std::fs::read_to_string(&app_config_file).ok()?; + let root: Value = serde_json::from_str(&content).ok()?; + let lang = root.get("app")?.get("language")?.as_str()?; + + match lang { + "zh-CN" => Some("zh-CN".to_string()), + "en-US" => Some("en-US".to_string()), + "zh" => Some("zh-CN".to_string()), + "en" => Some("en-US".to_string()), + _ => None, + } +} + fn read_or_create_root_config(app_config_file: &Path) -> Result { let mut root = if app_config_file.exists() { let content = std::fs::read_to_string(app_config_file) @@ -490,7 +1009,10 @@ fn apply_first_launch_language(app_language: &str) -> Result<(), String> { .or_insert_with(|| Value::Object(Map::new())) .as_object_mut() .ok_or_else(|| "Invalid app config object".to_string())?; - app_obj.insert("language".to_string(), Value::String(app_language.to_string())); + app_obj.insert( + "language".to_string(), + Value::String(app_language.to_string()), + ); write_root_config(&app_config_file, &root) } @@ -517,22 +1039,87 @@ fn apply_first_launch_model(model: &ModelConfig) -> Result<(), String> { .ok_or_else(|| "Invalid ai config object".to_string())?; let model_id = format!("installer_{}_{}", model.provider, chrono::Utc::now().timestamp()); - let model_json = serde_json::json!({ - "id": model_id, - "name": format!("{} - {}", model.provider, model.model_name), - "provider": model.format, - "model_name": model.model_name, - "base_url": model.base_url, - "api_key": model.api_key, - "enabled": true, - "category": "general_chat", - "capabilities": ["text_chat", "function_calling"], - "recommended_for": [], - "metadata": null, - "enable_thinking_process": false, - "support_preserved_thinking": false, - "skip_ssl_verify": false - }); + let display_name = model + .config_name + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()) + .unwrap_or_else(|| format!("{} - {}", model.provider, model.model_name)); + + let custom_request_body = parse_custom_request_body(&model.custom_request_body)?; + let api_format = normalize_api_format(model); + let request_url = resolve_request_url(model.base_url.trim(), &api_format); + let mut model_map = Map::new(); + model_map.insert("id".to_string(), Value::String(model_id.clone())); + model_map.insert("name".to_string(), Value::String(display_name)); + model_map.insert( + "provider".to_string(), + Value::String(api_format), + ); + model_map.insert( + "model_name".to_string(), + Value::String(model.model_name.trim().to_string()), + ); + model_map.insert( + "base_url".to_string(), + Value::String(model.base_url.trim().to_string()), + ); + model_map.insert("request_url".to_string(), Value::String(request_url)); + model_map.insert( + "api_key".to_string(), + Value::String(model.api_key.trim().to_string()), + ); + model_map.insert("enabled".to_string(), Value::Bool(true)); + model_map.insert( + "category".to_string(), + Value::String("general_chat".to_string()), + ); + model_map.insert( + "capabilities".to_string(), + Value::Array(vec![ + Value::String("text_chat".to_string()), + Value::String("function_calling".to_string()), + ]), + ); + model_map.insert("recommended_for".to_string(), Value::Array(Vec::new())); + model_map.insert("metadata".to_string(), Value::Null); + model_map.insert("enable_thinking_process".to_string(), Value::Bool(false)); + model_map.insert("support_preserved_thinking".to_string(), Value::Bool(false)); + + if let Some(skip_ssl_verify) = model.skip_ssl_verify { + model_map.insert("skip_ssl_verify".to_string(), Value::Bool(skip_ssl_verify)); + } + if let Some(headers) = &model.custom_headers { + let mut header_map = Map::new(); + for (key, value) in headers { + let key_trimmed = key.trim(); + if key_trimmed.is_empty() { + continue; + } + header_map.insert( + key_trimmed.to_string(), + Value::String(value.trim().to_string()), + ); + } + if !header_map.is_empty() { + model_map.insert("custom_headers".to_string(), Value::Object(header_map)); + let mode = model + .custom_headers_mode + .as_deref() + .unwrap_or("merge") + .trim() + .to_ascii_lowercase(); + if mode == "merge" || mode == "replace" { + model_map.insert("custom_headers_mode".to_string(), Value::String(mode)); + } + } + } + if let Some(extra_body) = custom_request_body { + model_map.insert("custom_request_body".to_string(), Value::Object(extra_body)); + } + + let model_json = Value::Object(model_map); let models_entry = ai_obj .entry("models".to_string()) @@ -560,6 +1147,101 @@ fn apply_first_launch_model(model: &ModelConfig) -> Result<(), String> { write_root_config(&app_config_file, &root) } +fn preflight_validate_payload_zip_bytes( + zip_bytes: &[u8], + source_label: &str, +) -> Result<(), String> { + let reader = Cursor::new(zip_bytes); + let mut archive = zip::ZipArchive::new(reader) + .map_err(|e| format!("Invalid zip from {source_label}: {e}"))?; + preflight_validate_payload_zip_archive(&mut archive, source_label) +} + +fn preflight_validate_payload_zip_file(path: &Path, source_label: &str) -> Result<(), String> { + let file = File::open(path) + .map_err(|e| format!("Failed to open payload zip ({source_label}): {e}"))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| format!("Invalid payload zip ({source_label}): {e}"))?; + preflight_validate_payload_zip_archive(&mut archive, source_label) +} + +fn preflight_validate_payload_zip_archive( + archive: &mut zip::ZipArchive, + source_label: &str, +) -> Result<(), String> { + let mut exe_size: Option = None; + for i in 0..archive.len() { + let file = archive + .by_index(i) + .map_err(|e| format!("Failed to read payload entry ({source_label}): {e}"))?; + if file.name().ends_with('/') { + continue; + } + let file_name = zip_entry_file_name(file.name()); + if file_name.eq_ignore_ascii_case("BitFun.exe") { + exe_size = Some(file.size()); + break; + } + } + + let size = exe_size + .ok_or_else(|| format!("Payload from {source_label} does not contain BitFun.exe"))?; + validate_payload_exe_size(size, source_label) +} + +fn preflight_validate_payload_dir(path: &Path, source_label: &str) -> Result<(), String> { + let app_exe = path.join("BitFun.exe"); + let meta = std::fs::metadata(&app_exe).map_err(|_| { + format!( + "Payload directory from {source_label} does not contain {}", + app_exe.display() + ) + })?; + validate_payload_exe_size(meta.len(), source_label) +} + +fn validate_payload_exe_size(size: u64, source_label: &str) -> Result<(), String> { + if size < MIN_WINDOWS_APP_EXE_BYTES { + return Err(format!( + "Payload BitFun.exe from {source_label} is too small ({size} bytes)" + )); + } + Ok(()) +} + +fn zip_entry_file_name(entry_name: &str) -> &str { + entry_name + .rsplit(&['/', '\\'][..]) + .next() + .unwrap_or(entry_name) +} + +fn is_payload_manifest_path(relative_path: &Path) -> bool { + relative_path + .file_name() + .and_then(|s| s.to_str()) + .map(|n| n.eq_ignore_ascii_case(PAYLOAD_MANIFEST_FILE)) + .unwrap_or(false) +} + +fn should_install_payload_path(relative_path: &Path) -> bool { + !is_payload_manifest_path(relative_path) +} + +fn verify_installed_payload(install_path: &Path) -> Result<(), String> { + let app_exe = install_path.join("BitFun.exe"); + let app_meta = std::fs::metadata(&app_exe) + .map_err(|_| "Installed BitFun.exe is missing after extraction".to_string())?; + if app_meta.len() < MIN_WINDOWS_APP_EXE_BYTES { + return Err(format!( + "Installed BitFun.exe is too small ({} bytes). Payload is likely invalid.", + app_meta.len() + )); + } + + Ok(()) +} + #[cfg(target_os = "windows")] fn rollback_installation( install_path: &Path, diff --git a/BitFun-Installer/src-tauri/src/installer/extract.rs b/BitFun-Installer/src-tauri/src/installer/extract.rs index adf5ff77..ad1dec37 100644 --- a/BitFun-Installer/src-tauri/src/installer/extract.rs +++ b/BitFun-Installer/src-tauri/src/installer/extract.rs @@ -1,25 +1,48 @@ use anyhow::{Context, Result}; use std::fs; use std::io; -use std::path::Path; +use std::io::Cursor; +use std::path::{Path, PathBuf}; /// Estimated install size in bytes (~200MB for typical Tauri app with WebView) pub const ESTIMATED_INSTALL_SIZE: u64 = 200 * 1024 * 1024; -/// Extract a zip archive to the target directory. -/// -/// In production, the payload is embedded via `include_bytes!` or read from -/// a resource bundled next to the installer executable. -pub fn extract_zip(archive_path: &Path, target_dir: &Path) -> Result<()> { +/// Extract a zip archive to the target directory with an entry filter. +pub fn extract_zip_with_filter( + archive_path: &Path, + target_dir: &Path, + should_extract: fn(&Path) -> bool, +) -> Result<()> { let file = fs::File::open(archive_path) .with_context(|| format!("Failed to open archive: {}", archive_path.display()))?; - let mut archive = zip::ZipArchive::new(file) - .with_context(|| "Failed to read zip archive")?; + let archive = zip::ZipArchive::new(file).with_context(|| "Failed to read zip archive")?; + extract_zip_archive(archive, target_dir, should_extract) +} + +/// Extract a zip archive from in-memory bytes with an entry filter. +pub fn extract_zip_bytes_with_filter( + archive_bytes: &[u8], + target_dir: &Path, + should_extract: fn(&Path) -> bool, +) -> Result<()> { + let reader = Cursor::new(archive_bytes); + let archive = zip::ZipArchive::new(reader).with_context(|| "Failed to read embedded zip")?; + extract_zip_archive(archive, target_dir, should_extract) +} +fn extract_zip_archive( + mut archive: zip::ZipArchive, + target_dir: &Path, + should_extract: fn(&Path) -> bool, +) -> Result<()> { for i in 0..archive.len() { let mut file = archive.by_index(i)?; - let out_path = target_dir.join(file.mangled_name()); + let rel_path: PathBuf = file.mangled_name(); + if !should_extract(&rel_path) { + continue; + } + let out_path = target_dir.join(&rel_path); if file.name().ends_with('/') { fs::create_dir_all(&out_path)?; @@ -35,10 +58,21 @@ pub fn extract_zip(archive_path: &Path, target_dir: &Path) -> Result<()> { Ok(()) } -/// Copy application files from a source directory to the target. -/// -/// Used during development or when the payload is pre-extracted beside the installer. -pub fn copy_directory(source: &Path, target: &Path) -> Result { +/// Copy files from source to target with a relative-path file filter. +pub fn copy_directory_with_filter( + source: &Path, + target: &Path, + should_copy_file: fn(&Path) -> bool, +) -> Result { + copy_directory_internal(source, target, Path::new(""), should_copy_file) +} + +fn copy_directory_internal( + source: &Path, + target: &Path, + relative_prefix: &Path, + should_copy_file: fn(&Path) -> bool, +) -> Result { let mut bytes_copied: u64 = 0; if !target.exists() { @@ -48,11 +82,16 @@ pub fn copy_directory(source: &Path, target: &Path) -> Result { for entry in fs::read_dir(source)? { let entry = entry?; let file_type = entry.file_type()?; - let dest = target.join(entry.file_name()); + let name = entry.file_name(); + let rel = relative_prefix.join(&name); + let dest = target.join(&name); if file_type.is_dir() { - bytes_copied += copy_directory(&entry.path(), &dest)?; + bytes_copied += copy_directory_internal(&entry.path(), &dest, &rel, should_copy_file)?; } else { + if !should_copy_file(&rel) { + continue; + } let size = entry.metadata()?.len(); fs::copy(entry.path(), &dest)?; bytes_copied += size; @@ -61,4 +100,3 @@ pub fn copy_directory(source: &Path, target: &Path) -> Result { Ok(bytes_copied) } - diff --git a/BitFun-Installer/src-tauri/src/installer/registry.rs b/BitFun-Installer/src-tauri/src/installer/registry.rs index 18181d57..c617cb84 100644 --- a/BitFun-Installer/src-tauri/src/installer/registry.rs +++ b/BitFun-Installer/src-tauri/src/installer/registry.rs @@ -33,6 +33,7 @@ pub fn register_uninstall_entry( key.set_value("InstallLocation", &install_path.to_string_lossy().as_ref())?; key.set_value("DisplayIcon", &icon_path)?; key.set_value("UninstallString", &uninstall_command)?; + key.set_value("QuietUninstallString", &uninstall_command)?; key.set_value("NoModify", &1u32)?; key.set_value("NoRepair", &1u32)?; diff --git a/BitFun-Installer/src-tauri/src/installer/shortcut.rs b/BitFun-Installer/src-tauri/src/installer/shortcut.rs index 8484cae8..4ce9e536 100644 --- a/BitFun-Installer/src-tauri/src/installer/shortcut.rs +++ b/BitFun-Installer/src-tauri/src/installer/shortcut.rs @@ -24,10 +24,7 @@ pub fn create_start_menu_shortcut(install_path: &Path) -> Result<()> { let exe_path = install_path.join("BitFun.exe"); create_lnk(&shortcut_path, &exe_path, install_path)?; - log::info!( - "Created Start Menu shortcut at {}", - shortcut_path.display() - ); + log::info!("Created Start Menu shortcut at {}", shortcut_path.display()); Ok(()) } @@ -54,8 +51,8 @@ pub fn remove_start_menu_shortcut() -> Result<()> { /// Get the current user's Start Menu Programs directory. fn get_start_menu_dir() -> Result { - let appdata = std::env::var("APPDATA") - .with_context(|| "APPDATA environment variable not set")?; + let appdata = + std::env::var("APPDATA").with_context(|| "APPDATA environment variable not set")?; Ok(PathBuf::from(appdata) .join("Microsoft") .join("Windows") @@ -67,7 +64,7 @@ fn get_start_menu_dir() -> Result { fn create_lnk(shortcut_path: &Path, target: &Path, _working_dir: &Path) -> Result<()> { let lnk = mslnk::ShellLink::new(target) .with_context(|| format!("Failed to create shell link for {}", target.display()))?; - + // Note: mslnk has limited API. For full control (icon, arguments, etc.), // consider using the windows crate with IShellLink COM interface. lnk.create_lnk(shortcut_path) diff --git a/BitFun-Installer/src-tauri/src/installer/types.rs b/BitFun-Installer/src-tauri/src/installer/types.rs index b0cfd100..27b3539f 100644 --- a/BitFun-Installer/src-tauri/src/installer/types.rs +++ b/BitFun-Installer/src-tauri/src/installer/types.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Installation options passed from the frontend #[derive(Debug, Clone, Serialize, Deserialize)] @@ -33,6 +34,27 @@ pub struct ModelConfig { pub base_url: String, pub model_name: String, pub format: String, + #[serde(default)] + pub config_name: Option, + #[serde(default)] + pub custom_request_body: Option, + #[serde(default)] + pub skip_ssl_verify: Option, + #[serde(default)] + pub custom_headers: Option>, + #[serde(default)] + pub custom_headers_mode: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionTestResult { + pub success: bool, + pub response_time_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_response: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_details: Option, } /// Progress update sent to the frontend diff --git a/BitFun-Installer/src-tauri/src/lib.rs b/BitFun-Installer/src-tauri/src/lib.rs index 126522ed..e6c66b3d 100644 --- a/BitFun-Installer/src-tauri/src/lib.rs +++ b/BitFun-Installer/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ pub fn run() { commands::validate_install_path, commands::start_installation, commands::set_model_config, + commands::test_model_config_connection, commands::set_theme_preference, commands::uninstall, commands::launch_application, diff --git a/BitFun-Installer/src/App.tsx b/BitFun-Installer/src/App.tsx index 83e6866e..ac6a3be5 100644 --- a/BitFun-Installer/src/App.tsx +++ b/BitFun-Installer/src/App.tsx @@ -58,6 +58,7 @@ function App() { options={installer.options} setOptions={installer.setOptions} onSkip={installer.next} + onTestConnection={installer.testModelConnection} onNext={async () => { await installer.saveModelConfig(); installer.next(); diff --git a/BitFun-Installer/src/data/modelProviders.ts b/BitFun-Installer/src/data/modelProviders.ts new file mode 100644 index 00000000..e5f7b42f --- /dev/null +++ b/BitFun-Installer/src/data/modelProviders.ts @@ -0,0 +1,155 @@ +import type { ModelConfig } from '../types/installer'; + +export type ApiFormat = 'openai' | 'anthropic'; + +export interface ProviderUrlOption { + url: string; + format: ApiFormat; + noteKey?: string; +} + +export interface ProviderTemplate { + id: string; + nameKey: string; + descriptionKey: string; + baseUrl: string; + format: ApiFormat; + models: string[]; + helpUrl?: string; + baseUrlOptions?: ProviderUrlOption[]; +} + +export const PROVIDER_DISPLAY_ORDER: string[] = [ + 'zhipu', + 'qwen', + 'deepseek', + 'volcengine', + 'minimax', + 'moonshot', + 'anthropic', +]; + +export const PROVIDER_TEMPLATES: Record = { + anthropic: { + id: 'anthropic', + nameKey: 'model.providers.anthropic.name', + descriptionKey: 'model.providers.anthropic.description', + baseUrl: 'https://api.anthropic.com', + format: 'anthropic', + models: ['claude-opus-4-6', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'], + helpUrl: 'https://console.anthropic.com/', + }, + minimax: { + id: 'minimax', + nameKey: 'model.providers.minimax.name', + descriptionKey: 'model.providers.minimax.description', + baseUrl: 'https://api.minimaxi.com/anthropic', + format: 'anthropic', + models: ['MiniMax-M2.1', 'MiniMax-M2.1-lightning', 'MiniMax-M2'], + helpUrl: 'https://platform.minimax.io/', + }, + moonshot: { + id: 'moonshot', + nameKey: 'model.providers.moonshot.name', + descriptionKey: 'model.providers.moonshot.description', + baseUrl: 'https://api.moonshot.cn/v1', + format: 'openai', + models: ['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking'], + helpUrl: 'https://platform.moonshot.ai/console', + }, + deepseek: { + id: 'deepseek', + nameKey: 'model.providers.deepseek.name', + descriptionKey: 'model.providers.deepseek.description', + baseUrl: 'https://api.deepseek.com', + format: 'openai', + models: ['deepseek-chat', 'deepseek-reasoner'], + helpUrl: 'https://platform.deepseek.com/api_keys', + }, + zhipu: { + id: 'zhipu', + nameKey: 'model.providers.zhipu.name', + descriptionKey: 'model.providers.zhipu.description', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + format: 'openai', + models: ['glm-5', 'glm-4.7'], + helpUrl: 'https://open.bigmodel.cn/usercenter/apikeys', + baseUrlOptions: [ + { + url: 'https://open.bigmodel.cn/api/paas/v4', + format: 'openai', + noteKey: 'model.providers.zhipu.urlOptions.default', + }, + { + url: 'https://open.bigmodel.cn/api/anthropic', + format: 'anthropic', + noteKey: 'model.providers.zhipu.urlOptions.anthropic', + }, + { + url: 'https://open.bigmodel.cn/api/coding/paas', + format: 'openai', + noteKey: 'model.providers.zhipu.urlOptions.codingPlan', + }, + ], + }, + qwen: { + id: 'qwen', + nameKey: 'model.providers.qwen.name', + descriptionKey: 'model.providers.qwen.description', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + format: 'openai', + models: ['qwen3-max', 'qwen3-coder-plus', 'qwen3-coder-flash'], + helpUrl: 'https://dashscope.console.aliyun.com/apiKey', + }, + volcengine: { + id: 'volcengine', + nameKey: 'model.providers.volcengine.name', + descriptionKey: 'model.providers.volcengine.description', + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + format: 'openai', + models: ['glm-4-7-251222', 'doubao-seed-code-preview-251028'], + helpUrl: 'https://console.volcengine.com/ark/', + }, +}; + +export function getOrderedProviders(): ProviderTemplate[] { + const ordered: ProviderTemplate[] = []; + for (const id of PROVIDER_DISPLAY_ORDER) { + const template = PROVIDER_TEMPLATES[id]; + if (template) ordered.push(template); + } + for (const template of Object.values(PROVIDER_TEMPLATES)) { + if (!PROVIDER_DISPLAY_ORDER.includes(template.id)) { + ordered.push(template); + } + } + return ordered; +} + +export function resolveProviderFormat(template: ProviderTemplate, baseUrl: string): ApiFormat { + if (template.baseUrlOptions && template.baseUrlOptions.length > 0) { + const selected = template.baseUrlOptions.find((item) => item.url === baseUrl.trim()); + if (selected) return selected.format; + } + return template.format; +} + +export function createModelConfigFromTemplate( + template: ProviderTemplate, + previous: ModelConfig | null +): ModelConfig { + const modelName = previous?.modelName?.trim() || template.models[0] || ''; + const baseUrl = previous?.baseUrl?.trim() || template.baseUrl; + return { + provider: template.id, + apiKey: previous?.apiKey || '', + modelName, + baseUrl, + format: resolveProviderFormat(template, baseUrl), + configName: `${template.id} - ${modelName}`.trim(), + customRequestBody: previous?.customRequestBody, + skipSslVerify: previous?.skipSslVerify, + customHeaders: previous?.customHeaders, + customHeadersMode: previous?.customHeadersMode || 'merge', + }; +} diff --git a/BitFun-Installer/src/hooks/useInstaller.ts b/BitFun-Installer/src/hooks/useInstaller.ts index 91396027..a4504f69 100644 --- a/BitFun-Installer/src/hooks/useInstaller.ts +++ b/BitFun-Installer/src/hooks/useInstaller.ts @@ -1,11 +1,14 @@ import { useState, useEffect, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; +import i18n from '../i18n'; import type { InstallStep, InstallOptions, InstallProgress, DiskSpaceInfo, + ModelConfig, + ConnectionTestResult, LaunchContext, } from '../types/installer'; import { DEFAULT_OPTIONS } from '../types/installer'; @@ -28,6 +31,7 @@ export interface UseInstallerReturn { retryInstall: () => Promise; backToOptions: () => void; saveModelConfig: () => Promise; + testModelConnection: (modelConfig: ModelConfig) => Promise; launchApp: () => Promise; closeInstaller: () => void; refreshDiskSpace: (path: string) => Promise; @@ -42,6 +46,19 @@ export interface UseInstallerReturn { const STEPS: InstallStep[] = ['lang', 'options', 'progress', 'model', 'theme']; const MOCK_INSTALL_FOR_DEBUG = import.meta.env.DEV && import.meta.env.VITE_MOCK_INSTALL === 'true'; +function resolveUiLanguage(appLanguage?: string | null): 'zh' | 'en' { + if (appLanguage === 'zh-CN') return 'zh'; + if (appLanguage === 'en-US') return 'en'; + if (typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')) { + return 'zh'; + } + return 'en'; +} + +function mapUiLanguageToAppLanguage(uiLanguage: 'zh' | 'en'): 'zh-CN' | 'en-US' { + return uiLanguage === 'zh' ? 'zh-CN' : 'en-US'; +} + export function useInstaller(): UseInstallerReturn { const [step, setStep] = useState('lang'); const [options, setOptions] = useState(DEFAULT_OPTIONS); @@ -67,6 +84,13 @@ export function useInstaller(): UseInstallerReturn { try { const context = await invoke('get_launch_context'); if (!mounted) return; + const uiLanguage = resolveUiLanguage(context.appLanguage ?? null); + await i18n.changeLanguage(uiLanguage); + if (!mounted) return; + setOptions((prev) => ({ + ...prev, + appLanguage: mapUiLanguageToAppLanguage(uiLanguage), + })); if (context.mode === 'uninstall') { setIsUninstallMode(true); setStep('uninstall'); @@ -193,12 +217,12 @@ export function useInstaller(): UseInstallerReturn { await invoke('set_model_config', { modelConfig: options.modelConfig }); }, [options.modelConfig]); + const testModelConnection = useCallback(async (modelConfig: ModelConfig) => { + return invoke('test_model_config_connection', { modelConfig }); + }, []); + const launchApp = useCallback(async () => { - try { - await invoke('launch_application', { installPath: options.installPath }); - } catch (err) { - console.error('Failed to launch application:', err); - } + await invoke('launch_application', { installPath: options.installPath }); }, [options.installPath]); const closeInstaller = useCallback(() => { @@ -230,20 +254,23 @@ export function useInstaller(): UseInstallerReturn { await invoke('uninstall', { installPath: options.installPath }); setUninstallProgress(100); setUninstallCompleted(true); + window.setTimeout(() => { + closeInstaller(); + }, 600); } catch (err: any) { setUninstallError(typeof err === 'string' ? err : err.message || 'Uninstall failed'); setUninstallProgress(0); } finally { setIsUninstalling(false); } - }, [isUninstalling, options.installPath]); + }, [closeInstaller, isUninstalling, options.installPath]); return { step, goTo, next, back, options, setOptions, progress, isInstalling, installationCompleted, error, diskSpace, install, canConfirmProgress, confirmProgress, retryInstall, backToOptions, - saveModelConfig, launchApp, closeInstaller, refreshDiskSpace, + saveModelConfig, testModelConnection, launchApp, closeInstaller, refreshDiskSpace, isUninstallMode, isUninstalling, uninstallCompleted, uninstallError, uninstallProgress, startUninstall, }; } diff --git a/BitFun-Installer/src/i18n/locales/en.json b/BitFun-Installer/src/i18n/locales/en.json index 2b19d931..c3b95883 100644 --- a/BitFun-Installer/src/i18n/locales/en.json +++ b/BitFun-Installer/src/i18n/locales/en.json @@ -39,7 +39,68 @@ "apiKey": "API key", "back": "Back", "skip": "Skip for now", - "nextTheme": "Next: Theme" + "nextTheme": "Next: Theme", + "description": "Configuring an AI model is required to use BitFun. Select a provider and enter your API information", + "providerLabel": "Model Provider", + "selectProvider": "Select a model provider...", + "customProvider": "Custom", + "getApiKey": "How to get an API Key?", + "modelNamePlaceholder": "Enter model name...", + "baseUrlPlaceholder": "e.g., https://open.bigmodel.cn/api/paas/v4/chat/completions", + "customRequestBodyPlaceholder": "{\n \"temperature\": 0.8,\n \"top_p\": 0.9\n}", + "jsonValid": "Valid JSON format", + "jsonInvalid": "Invalid JSON format, please check syntax", + "skipSslVerify": "Skip SSL Certificate Verification", + "customHeadersModeMerge": "Merge Override", + "customHeadersModeReplace": "Replace All", + "addHeader": "Add Field", + "headerKey": "key", + "headerValue": "value", + "advancedShow": "Show advanced settings", + "advancedHide": "Hide advanced settings", + "providers": { + "anthropic": { + "name": "Anthropic Claude", + "description": "Anthropic Claude series models" + }, + "minimax": { + "name": "MiniMax", + "description": "MiniMax M2 series large language models" + }, + "moonshot": { + "name": "Moonshot AI", + "description": "Moonshot Kimi K2 series models" + }, + "deepseek": { + "name": "DeepSeek", + "description": "DeepSeek V3 and R1 reasoning models" + }, + "zhipu": { + "name": "Zhipu AI", + "description": "Zhipu AI GLM series models", + "urlOptions": { + "default": "OpenAI Format - Default", + "anthropic": "Anthropic Format", + "codingPlan": "OpenAI Format - CodingPlan" + } + }, + "qwen": { + "name": "Qwen", + "description": "Alibaba Cloud Qwen3 series models" + }, + "volcengine": { + "name": "Volcano Engine", + "description": "ByteDance Volcano Engine Doubao large language models" + } + }, + "modelNameSelectPlaceholder": "Select a model...", + "customModel": "Use custom model name", + "testConnection": "Test Connection", + "testing": "Testing...", + "testSuccess": "Connection successful", + "testFailed": "Connection failed", + "modelSearchPlaceholder": "Search or enter a custom model name...", + "modelNoResults": "No matching models" }, "progress": { "title": "Installing", @@ -59,7 +120,16 @@ "themeSetup": { "title": "Theme & Launch", "subtitle": "Choose your startup theme, then launch", - "skip": "Skip theme and launch" + "skip": "Skip theme and launch", + "themeNames": { + "bitfun-dark": "Dark", + "bitfun-light": "Light", + "bitfun-midnight": "Midnight", + "bitfun-china-style": "Ink Charm", + "bitfun-china-night": "Ink Night", + "bitfun-cyber": "Cyber", + "bitfun-slate": "Slate" + } }, "complete": { "title": "Complete", diff --git a/BitFun-Installer/src/i18n/locales/zh.json b/BitFun-Installer/src/i18n/locales/zh.json index 8c94cb0d..3b08d922 100644 --- a/BitFun-Installer/src/i18n/locales/zh.json +++ b/BitFun-Installer/src/i18n/locales/zh.json @@ -39,7 +39,68 @@ "apiKey": "API Key", "back": "返回", "skip": "稍后配置", - "nextTheme": "下一步:主题" + "nextTheme": "下一步:主题", + "description": "配置 AI 模型是使用 BitFun 的前提,请选择模型服务商并填写 API 信息", + "providerLabel": "模型服务商", + "selectProvider": "选择模型服务商...", + "customProvider": "自定义", + "getApiKey": "如何获取 API Key?", + "modelNamePlaceholder": "输入模型名称...", + "baseUrlPlaceholder": "示例:https://open.bigmodel.cn/api/paas/v4/chat/completions", + "customRequestBodyPlaceholder": "{\n \"temperature\": 0.8,\n \"top_p\": 0.9\n}", + "jsonValid": "JSON 格式有效", + "jsonInvalid": "JSON 格式错误,请检查语法", + "skipSslVerify": "跳过SSL证书验证", + "customHeadersModeMerge": "合并覆盖", + "customHeadersModeReplace": "完全替换", + "addHeader": "添加字段", + "headerKey": "key", + "headerValue": "value", + "advancedShow": "Show advanced settings", + "advancedHide": "Hide advanced settings", + "providers": { + "anthropic": { + "name": "Anthropic Claude", + "description": "Anthropic Claude 系列模型" + }, + "minimax": { + "name": "MiniMax", + "description": "MiniMax M2 系列大语言模型" + }, + "moonshot": { + "name": "月之暗面", + "description": "月之暗面 Kimi K2 系列模型" + }, + "deepseek": { + "name": "DeepSeek", + "description": "DeepSeek V3 和 R1 推理模型" + }, + "zhipu": { + "name": "智谱AI", + "description": "智谱AI GLM 系列模型", + "urlOptions": { + "default": "OpenAI格式-默认", + "anthropic": "Anthropic格式", + "codingPlan": "OpenAI格式-CodingPlan" + } + }, + "qwen": { + "name": "通义千问", + "description": "阿里云通义千问 Qwen3 系列模型" + }, + "volcengine": { + "name": "火山引擎", + "description": "字节跳动火山引擎豆包大模型" + } + }, + "modelNameSelectPlaceholder": "选择模型...", + "customModel": "使用自定义模型名称", + "testConnection": "测试连接", + "testing": "测试中...", + "testSuccess": "连接成功", + "testFailed": "连接失败", + "modelSearchPlaceholder": "搜索或输入自定义模型名称...", + "modelNoResults": "没有匹配的模型" }, "progress": { "title": "安装中", @@ -59,7 +120,16 @@ "themeSetup": { "title": "主题与启动", "subtitle": "选择首次启动主题,然后开始使用", - "skip": "跳过主题并启动" + "skip": "跳过主题并启动", + "themeNames": { + "bitfun-dark": "暗色", + "bitfun-light": "亮色", + "bitfun-midnight": "午夜", + "bitfun-china-style": "墨韵", + "bitfun-china-night": "墨夜", + "bitfun-cyber": "赛博", + "bitfun-slate": "石板灰" + } }, "complete": { "title": "完成", diff --git a/BitFun-Installer/src/pages/ModelSetup.tsx b/BitFun-Installer/src/pages/ModelSetup.tsx index c7ff3a9a..509f39c4 100644 --- a/BitFun-Installer/src/pages/ModelSetup.tsx +++ b/BitFun-Installer/src/pages/ModelSetup.tsx @@ -1,149 +1,458 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { InstallOptions } from '../types/installer'; +import { + createModelConfigFromTemplate, + getOrderedProviders, + PROVIDER_TEMPLATES, + resolveProviderFormat, + type ApiFormat, + type ProviderTemplate, +} from '../data/modelProviders'; +import type { ConnectionTestResult, InstallOptions, ModelConfig } from '../types/installer'; + +type TestStatus = 'idle' | 'testing' | 'success' | 'error'; +const CUSTOM_MODEL_OPTION = '__custom_model__'; + +interface SelectOption { + value: string; + label: string; + description?: string; +} interface ModelSetupProps { options: InstallOptions; setOptions: React.Dispatch>; onSkip: () => void; onNext: () => Promise; + onTestConnection: (modelConfig: ModelConfig) => Promise; } -const PROVIDERS = [ - { id: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', format: 'openai' as const }, - { id: 'qwen', label: 'Qwen', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', format: 'openai' as const }, - { id: 'zhipu', label: 'Zhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', format: 'openai' as const }, - { id: 'anthropic', label: 'Anthropic', baseUrl: 'https://api.anthropic.com/v1', format: 'anthropic' as const }, -]; +interface SimpleSelectProps { + value: string; + options: SelectOption[]; + placeholder: string; + onChange: (value: string) => void; + searchable?: boolean; + searchPlaceholder?: string; + emptyText?: string; + alwaysVisibleValues?: string[]; +} -export function ModelSetup({ options, setOptions, onSkip, onNext }: ModelSetupProps) { - const { t } = useTranslation(); +function SimpleSelect({ + value, + options, + placeholder, + onChange, + searchable = false, + searchPlaceholder, + emptyText, + alwaysVisibleValues = [], +}: SimpleSelectProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const rootRef = useRef(null); + const selected = useMemo(() => options.find((item) => item.value === value) || null, [options, value]); + const filteredOptions = useMemo(() => { + if (!searchable || !search.trim()) return options; + const keyword = search.trim().toLowerCase(); + return options.filter((item) => { + if (alwaysVisibleValues.includes(item.value)) return true; + const label = item.label.toLowerCase(); + const desc = item.description?.toLowerCase() || ''; + return label.includes(keyword) || desc.includes(keyword); + }); + }, [options, search, searchable, alwaysVisibleValues]); - const current = options.modelConfig; - const selectedProvider = useMemo( - () => PROVIDERS.find((p) => p.id === current?.provider) ?? null, - [current?.provider], + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current) return; + if (!rootRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('pointerdown', onPointerDown); + return () => document.removeEventListener('pointerdown', onPointerDown); + }, [open]); + + return ( +
+ + + {open && ( +
+ {searchable && ( +
+ setSearch(e.target.value)} + /> +
+ )} + {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + )) + ) : ( +
{emptyText || 'No results'}
+ )} +
+ )} +
); +} - const setProvider = (providerId: string) => { - const provider = PROVIDERS.find((p) => p.id === providerId); - if (!provider) { - setOptions((prev) => ({ ...prev, modelConfig: null })); - return; - } +export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnection }: ModelSetupProps) { + const { t } = useTranslation(); + const providers = useMemo(() => getOrderedProviders(), []); + const current = options.modelConfig; + + const [selectedProviderId, setSelectedProviderId] = useState(current?.provider || ''); + const [apiKey, setApiKey] = useState(current?.apiKey || ''); + const [baseUrl, setBaseUrl] = useState(current?.baseUrl || ''); + const [modelName, setModelName] = useState(current?.modelName || ''); + const [customFormat, setCustomFormat] = useState((current?.format as ApiFormat) || 'openai'); + const [forceCustomModelInput, setForceCustomModelInput] = useState(false); + + const [testStatus, setTestStatus] = useState('idle'); + const [testMessage, setTestMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isCustomProvider = selectedProviderId === 'custom'; + const template = useMemo(() => { + if (!selectedProviderId || selectedProviderId === 'custom') return null; + return PROVIDER_TEMPLATES[selectedProviderId] || null; + }, [selectedProviderId]); + + const effectiveBaseUrl = useMemo(() => { + if (isCustomProvider) return baseUrl.trim(); + if (baseUrl.trim()) return baseUrl.trim(); + return template?.baseUrl || ''; + }, [isCustomProvider, baseUrl, template]); + + const effectiveModelName = useMemo(() => { + if (modelName.trim()) return modelName.trim(); + return template?.models[0] || ''; + }, [modelName, template]); + + const effectiveFormat = useMemo(() => { + if (isCustomProvider || !template) return customFormat; + return resolveProviderFormat(template, effectiveBaseUrl); + }, [isCustomProvider, template, customFormat, effectiveBaseUrl]); + + const draftModelConfig = useMemo(() => { + if (!selectedProviderId) return null; + + const providerDisplayName = template + ? t(template.nameKey, { defaultValue: template.id }) + : t('model.customProvider', { defaultValue: 'Custom' }); + const configName = `${providerDisplayName} - ${effectiveModelName}`.trim(); + + return { + provider: selectedProviderId, + apiKey, + baseUrl: effectiveBaseUrl, + modelName: effectiveModelName, + format: effectiveFormat, + configName, + }; + }, [ + selectedProviderId, + template, + apiKey, + effectiveBaseUrl, + effectiveModelName, + effectiveFormat, + t, + ]); + + const canContinue = Boolean(selectedProviderId && apiKey.trim() && effectiveBaseUrl && effectiveModelName); + + const canTestConnection = canContinue && testStatus !== 'testing'; + + useEffect(() => { setOptions((prev) => ({ ...prev, - modelConfig: { - provider: provider.id, - apiKey: prev.modelConfig?.apiKey ?? '', - baseUrl: provider.baseUrl, - modelName: prev.modelConfig?.modelName ?? '', - format: provider.format, - }, + modelConfig: draftModelConfig, })); - }; + }, [draftModelConfig, setOptions]); - const updateField = (field: 'apiKey' | 'modelName', value: string) => { - setOptions((prev) => { - if (!prev.modelConfig) return prev; - return { ...prev, modelConfig: { ...prev.modelConfig, [field]: value } }; - }); - }; + const resetTestState = useCallback(() => { + setTestStatus('idle'); + setTestMessage(''); + }, []); - const canContinue = Boolean( - current && current.provider && current.apiKey.trim() && current.modelName.trim() && current.baseUrl.trim(), - ); + const handleProviderSelect = useCallback((providerId: string) => { + resetTestState(); + setSelectedProviderId(providerId); + setForceCustomModelInput(false); + if (providerId === 'custom') { + setBaseUrl(''); + setModelName(''); + setCustomFormat('openai'); + return; + } + const nextTemplate = PROVIDER_TEMPLATES[providerId]; + if (!nextTemplate) return; + const next = createModelConfigFromTemplate(nextTemplate, null); + setBaseUrl(next.baseUrl); + setModelName(next.modelName); + setCustomFormat(next.format); + }, [resetTestState]); + + const handleTestConnection = useCallback(async () => { + if (!draftModelConfig || !canTestConnection) return; + setTestStatus('testing'); + setTestMessage(t('model.testing', { defaultValue: 'Testing...' })); + try { + const result = await onTestConnection(draftModelConfig); + if (result.success) { + setTestStatus('success'); + setTestMessage(t('model.testSuccess', { defaultValue: 'Connection successful' })); + } else { + setTestStatus('error'); + setTestMessage(result.errorDetails || t('model.testFailed', { defaultValue: 'Connection failed' })); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setTestStatus('error'); + setTestMessage(message || t('model.testFailed', { defaultValue: 'Connection failed' })); + } + }, [draftModelConfig, canTestConnection, onTestConnection, t]); + + const handleContinue = useCallback(async () => { + if (!canContinue) return; + setIsSubmitting(true); + try { + await onNext(); + } catch (error) { + setTestStatus('error'); + setTestMessage(error instanceof Error ? error.message : String(error)); + } finally { + setIsSubmitting(false); + } + }, [canContinue, onNext]); + + const providerOptions = useMemo(() => { + return [ + { value: 'custom', label: t('model.customProvider', { defaultValue: 'Custom' }) }, + ...providers.map((provider) => ({ + value: provider.id, + label: t(provider.nameKey, { defaultValue: provider.id }), + })), + ]; + }, [providers, t]); + + const baseUrlOptions = useMemo(() => { + if (!template?.baseUrlOptions?.length) return []; + return template.baseUrlOptions.map((opt) => ({ + value: opt.url, + label: opt.url, + description: `${opt.format.toUpperCase()} / ${opt.noteKey ? t(opt.noteKey, { defaultValue: 'default' }) : 'default'}`, + })); + }, [template, t]); + + const modelOptions = useMemo(() => { + if (!template) return []; + return [ + ...template.models.map((item) => ({ value: item, label: item })), + { + value: CUSTOM_MODEL_OPTION, + label: t('model.customModel', { defaultValue: 'Use custom model name' }), + }, + ]; + }, [template, t]); + + const modelSelectionValue = useMemo(() => { + if (!template) return ''; + if (forceCustomModelInput) return CUSTOM_MODEL_OPTION; + const trimmed = modelName.trim(); + if (!trimmed) return template.models[0] || ''; + if (template.models.includes(trimmed)) return trimmed; + return CUSTOM_MODEL_OPTION; + }, [template, modelName, forceCustomModelInput]); + + const customFormatOptions: SelectOption[] = [ + { value: 'openai', label: 'OpenAI Compatible' }, + { value: 'anthropic', label: 'Anthropic' }, + ]; return ( -
-
- - {t('model.installDone')} -
-
- {t('model.subtitle')} -
+
+
+
+
+ {t('model.subtitle')} +
+
+ {t('model.description', { defaultValue: 'Configure AI model provider and API key.' })} +
-
{t('model.provider')}
-
- {PROVIDERS.map((provider) => { - const active = current?.provider === provider.id; - return ( - - ); - })} -
+
{t('model.providerLabel', { defaultValue: 'Model Provider' })}
+ -
{t('model.config')}
-
- updateField('modelName', e.target.value)} - /> - updateField('apiKey', e.target.value)} - /> - + {template && ( +
+ {t(template.descriptionKey, { defaultValue: '' })} +
+ )} + + {!!selectedProviderId && ( +
+ {template ? ( + <> + { + if (next === CUSTOM_MODEL_OPTION) { + setForceCustomModelInput(true); + if (template.models.includes(modelName.trim())) { + setModelName(''); + } + resetTestState(); + return; + } + setForceCustomModelInput(false); + setModelName(next); + resetTestState(); + }} + /> + {(forceCustomModelInput || (modelName.trim() && !template.models.includes(modelName.trim()))) && ( + { + setModelName(e.target.value); + resetTestState(); + }} + /> + )} + + ) : ( + { + setModelName(e.target.value); + resetTestState(); + }} + /> + )} + + {baseUrlOptions.length > 0 ? ( + { + setBaseUrl(next); + resetTestState(); + }} + /> + ) : ( + { + setBaseUrl(e.target.value); + resetTestState(); + }} + /> + )} + + { + setApiKey(e.target.value); + resetTestState(); + }} + /> + + {isCustomProvider && ( + { + setCustomFormat((next as ApiFormat) || 'openai'); + resetTestState(); + }} + /> + )} +
+ )} + + {!!selectedProviderId && ( +
+ + {testStatus === 'success' && ( + {testMessage} + )} + {testStatus === 'error' && ( + {testMessage} + )} +
+ )} +
-
+
-
); } - diff --git a/BitFun-Installer/src/pages/Options.tsx b/BitFun-Installer/src/pages/Options.tsx index 475c505f..6b08d461 100644 --- a/BitFun-Installer/src/pages/Options.tsx +++ b/BitFun-Installer/src/pages/Options.tsx @@ -81,7 +81,6 @@ export function Options({ update('startMenu', v)} label={t('options.startMenu')} /> update('contextMenu', v)} label={t('options.contextMenu')} /> update('addToPath', v)} label={t('options.addToPath')} /> - update('launchAfterInstall', v)} label={t('options.launchAfterInstall')} />
diff --git a/BitFun-Installer/src/pages/ThemeSetup.tsx b/BitFun-Installer/src/pages/ThemeSetup.tsx index cd476d7d..6d0a349d 100644 --- a/BitFun-Installer/src/pages/ThemeSetup.tsx +++ b/BitFun-Installer/src/pages/ThemeSetup.tsx @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; +import { Checkbox } from '../components/Checkbox'; import type { InstallOptions, ThemeId } from '../types/installer'; type InstallerTheme = { @@ -172,6 +173,8 @@ interface ThemeSetupProps { export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetupProps) { const { t } = useTranslation(); + const [isFinishing, setIsFinishing] = useState(false); + const [finishError, setFinishError] = useState(null); const orderedThemes = [...THEMES].sort((a, b) => THEME_DISPLAY_ORDER.indexOf(a.id) - THEME_DISPLAY_ORDER.indexOf(b.id)); const selectTheme = (theme: ThemeId) => { @@ -196,12 +199,27 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetu marginBottom: 8, }; - const handleLaunch = async () => { - await invoke('set_theme_preference', { themePreference: options.themePreference }); - if (options.launchAfterInstall) { - await onLaunch(); + const handleFinish = async () => { + if (isFinishing) return; + setIsFinishing(true); + setFinishError(null); + + try { + try { + await invoke('set_theme_preference', { themePreference: options.themePreference }); + } catch (err) { + console.warn('Failed to persist theme preference:', err); + } + + if (options.launchAfterInstall) { + await onLaunch(); + } + onClose(); + } catch (err: any) { + setFinishError(typeof err === 'string' ? err : err?.message || 'Failed to launch BitFun'); + } finally { + setIsFinishing(false); } - onClose(); }; useEffect(() => { @@ -284,17 +302,44 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetu
-
{theme.name}
+
+ {t(`themeSetup.themeNames.${theme.id}`, { defaultValue: theme.name })} +
))} +
+ setOptions((prev) => ({ ...prev, launchAfterInstall: checked }))} + label={t('options.launchAfterInstall')} + /> +
+ + {finishError && ( +
+ {finishError} +
+ )} +
-
diff --git a/BitFun-Installer/src/styles/global.css b/BitFun-Installer/src/styles/global.css index e67fbccd..063a86bd 100644 --- a/BitFun-Installer/src/styles/global.css +++ b/BitFun-Installer/src/styles/global.css @@ -211,6 +211,201 @@ html, body, #root { .input:focus { border-color: var(--color-accent-400); background: var(--element-bg-soft); } .input::placeholder { color: var(--color-text-muted); } +.bf-select { + position: relative; + width: 100%; +} + +.bf-select-trigger { + width: 100%; + min-height: 34px; + padding: 8px 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--color-text-primary); + cursor: pointer; + text-align: left; + transition: border-color var(--motion-fast) ease, background var(--motion-fast) ease; +} + +.bf-select-trigger:hover { + border-color: var(--border-medium); + background: var(--element-bg-soft); +} + +.bf-select-trigger--open { + border-color: var(--color-accent-400); +} + +.bf-select-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-family: var(--font-mono); + color: var(--color-text-primary); +} + +.bf-select-value--placeholder { + color: var(--color-text-muted); +} + +.bf-select-caret { + flex-shrink: 0; + color: var(--color-text-secondary); + transition: transform var(--motion-fast) ease; +} + +.bf-select-caret--open { + transform: rotate(180deg); +} + +.bf-select-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 20; + max-height: 176px; + overflow-y: auto; + padding: 4px; + background: var(--color-bg-secondary); + border: 1px solid var(--border-medium); + border-radius: var(--radius-sm); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25); +} + +.bf-select-search { + position: sticky; + top: 0; + z-index: 1; + padding: 2px 2px 6px; + background: var(--color-bg-secondary); +} + +.bf-select-search-input { + width: 100%; + min-height: 30px; + padding: 6px 8px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--element-bg-subtle); + color: var(--color-text-primary); + font-size: 12px; + font-family: var(--font-mono); + outline: none; +} + +.bf-select-search-input:focus { + border-color: var(--color-accent-400); + background: var(--element-bg-soft); +} + +.bf-select-search-input::placeholder { + color: var(--color-text-muted); +} + +.bf-select-option { + width: 100%; + border: 0; + background: transparent; + color: var(--color-text-primary); + padding: 8px; + border-radius: var(--radius-sm); + text-align: left; + display: flex; + flex-direction: column; + gap: 2px; + cursor: pointer; +} + +.bf-select-option:hover { + background: var(--element-bg-subtle); +} + +.bf-select-option--active { + background: var(--element-bg-soft); +} + +.bf-select-option-label { + font-size: 12px; + line-height: 1.2; + color: var(--color-text-primary); +} + +.bf-select-option-desc { + font-size: 11px; + line-height: 1.2; + color: var(--color-text-muted); +} + +.bf-select-empty { + padding: 8px; + color: var(--color-text-muted); + font-size: 12px; +} + +.model-setup-page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.model-setup-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 10px 22px 8px; +} + +.model-setup-container { + width: 100%; + max-width: 620px; + margin: 0 auto; +} + +.model-setup-fields { + display: grid; + gap: 8px; + margin-top: 6px; +} + +.model-setup-test-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 8px; + flex-wrap: wrap; +} + +.model-setup-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + padding: 10px 22px 12px; + border-top: 1px solid rgba(148, 163, 184, 0.12); + background: var(--color-bg-primary); + flex-shrink: 0; +} + +@media (max-width: 760px) { + .model-setup-scroll { + padding: 8px 14px 6px; + } + + .model-setup-footer { + padding: 8px 14px 10px; + } +} + .checkbox-group { display: flex; flex-direction: column; gap: 2px; } .checkbox-item { diff --git a/BitFun-Installer/src/types/installer.ts b/BitFun-Installer/src/types/installer.ts index c9a1585f..41c540ab 100644 --- a/BitFun-Installer/src/types/installer.ts +++ b/BitFun-Installer/src/types/installer.ts @@ -4,6 +4,7 @@ export type InstallStep = 'lang' | 'options' | 'model' | 'progress' | 'theme' | export interface LaunchContext { mode: 'install' | 'uninstall'; uninstallPath: string | null; + appLanguage?: 'zh-CN' | 'en-US' | null; } export type ThemeId = @@ -21,6 +22,18 @@ export interface ModelConfig { baseUrl: string; modelName: string; format: 'openai' | 'anthropic'; + configName?: string; + customRequestBody?: string; + skipSslVerify?: boolean; + customHeaders?: Record; + customHeadersMode?: 'merge' | 'replace'; +} + +export interface ConnectionTestResult { + success: boolean; + responseTimeMs: number; + modelResponse?: string; + errorDetails?: string; } /** Installation options sent to the Rust backend */ diff --git a/Cargo.toml b/Cargo.toml index 3eba81e9..e2baec5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,3 +119,10 @@ opt-level = 3 lto = true codegen-units = 1 strip = true + +[profile.release-fast] +inherits = "release" +lto = false +codegen-units = 16 +strip = false +incremental = true diff --git a/package.json b/package.json index 369d2b36..745e7282 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,17 @@ "preview": "cd src/web-ui && vite preview", "desktop:dev": "node scripts/dev.cjs desktop", "desktop:dev:raw": "cd src/apps/desktop && npm exec -- tauri dev", - "desktop:build": "npm run build:web && cd src/apps/desktop && npm exec -- tauri build", - "desktop:build:exe": "npm run build:web && cd src/apps/desktop && npm exec -- tauri build --no-bundle", - "desktop:build:nsis": "npm run build:web && cd src/apps/desktop && npm exec -- tauri build --bundles nsis", - "desktop:build:x86_64": "npm run build:web && cd src/apps/desktop && npm exec -- tauri build --target x86_64-apple-darwin", + "desktop:build": "cd src/apps/desktop && npm exec -- tauri build", + "desktop:build:fast": "cd src/apps/desktop && npm exec -- tauri build --debug --no-bundle", + "desktop:build:release-fast": "cd src/apps/desktop && npm exec -- tauri build --no-bundle -- --profile release-fast", + "desktop:build:exe": "cd src/apps/desktop && npm exec -- tauri build --no-bundle", + "desktop:build:nsis": "cd src/apps/desktop && npm exec -- tauri build --bundles nsis", + "desktop:build:x86_64": "cd src/apps/desktop && npm exec -- tauri build --target x86_64-apple-darwin", + "installer:build": "npm --prefix BitFun-Installer run installer:build", + "installer:build:fast": "npm --prefix BitFun-Installer run installer:build:fast", + "installer:build:only": "npm --prefix BitFun-Installer run installer:build:only", + "installer:build:only:fast": "npm --prefix BitFun-Installer run installer:build:only:fast", + "installer:dev": "npm --prefix BitFun-Installer run installer:dev", "cli:dev": "cd src/apps/cli && cargo run --", "cli:build": "cd src/apps/cli && cargo build --release", "cli:run": "cd src/apps/cli && cargo run --release --", diff --git a/src/crates/core/src/util/types/config.rs b/src/crates/core/src/util/types/config.rs index 76e2b394..bc0212fd 100644 --- a/src/crates/core/src/util/types/config.rs +++ b/src/crates/core/src/util/types/config.rs @@ -2,6 +2,34 @@ use log::warn; use crate::service::config::types::AIModelConfig; use serde::{Deserialize, Serialize}; +fn append_endpoint(base_url: &str, endpoint: &str) -> String { + let base = base_url.trim(); + if base.is_empty() { + return endpoint.to_string(); + } + if base.ends_with(endpoint) { + return base.to_string(); + } + format!("{}/{}", base.trim_end_matches('/'), endpoint) +} + +fn resolve_request_url(base_url: &str, provider: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/').to_string(); + if trimmed.is_empty() { + return String::new(); + } + + if let Some(stripped) = trimmed.strip_suffix('#') { + return stripped.trim_end_matches('/').to_string(); + } + + match provider.trim().to_ascii_lowercase().as_str() { + "openai" => append_endpoint(&trimmed, "chat/completions"), + "anthropic" => append_endpoint(&trimmed, "v1/messages"), + _ => trimmed, + } +} + /// AI client configuration (for AI requests) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AIConfig { @@ -41,11 +69,11 @@ impl TryFrom for AIConfig { None }; - // Use stored request_url if present, otherwise fall back to base_url (legacy configs) + // Use stored request_url if present; otherwise derive from base_url + provider for legacy configs. let request_url = other .request_url .filter(|u| !u.is_empty()) - .unwrap_or_else(|| other.base_url.clone()); + .unwrap_or_else(|| resolve_request_url(&other.base_url, &other.provider)); Ok(AIConfig { name: other.name.clone(), diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index a49358dc..20607663 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -31,6 +31,7 @@ const log = createLogger('App'); */ // Minimum time (ms) the splash is shown, so the animation is never a flash. const MIN_SPLASH_MS = 900; +const ENABLE_MAIN_ONBOARDING = false; function App() { // AI initialization @@ -69,6 +70,13 @@ function App() { // Initialize onboarding: check first launch on startup useEffect(() => { + if (!ENABLE_MAIN_ONBOARDING) { + onboardingService.markCompleted().catch((error) => { + log.warn('Failed to persist onboarding completion while disabled', error); + }); + return; + } + onboardingService.initialize().catch((error) => { log.error('Failed to initialize onboarding service', error); }); @@ -76,6 +84,11 @@ function App() { // In development, trigger onboarding via window.showOnboarding() useEffect(() => { + if (!ENABLE_MAIN_ONBOARDING) { + delete (window as any).showOnboarding; + return; + } + (window as any).showOnboarding = () => { forceShowOnboarding(); log.debug('Onboarding activated via debug command'); @@ -245,7 +258,7 @@ function App() { {/* Onboarding overlay (first launch) */} - {isOnboardingActive && ( + {ENABLE_MAIN_ONBOARDING && isOnboardingActive && ( diff --git a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts index bb09f91f..4d9c2689 100644 --- a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts +++ b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts @@ -56,7 +56,7 @@ export const PROVIDER_TEMPLATES: Record = { name: t('settings/ai-model:providers.zhipu.name'), baseUrl: 'https://open.bigmodel.cn/api/paas/v4', format: 'openai', - models: ['glm-5', 'glm-4.7', 'glm-4.6'], + models: ['glm-5', 'glm-4.7'], requiresApiKey: true, description: t('settings/ai-model:providers.zhipu.description'), helpUrl: 'https://open.bigmodel.cn/usercenter/apikeys',