diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 44e90b0601..62fae77710 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -309,6 +309,9 @@ jobs: - name: Verify release version metadata run: node scripts/verify-release-version-sync.mjs --version "${{ needs.prepare.outputs.version }}" + - name: Download and verify target Flashgrep binary + run: node scripts/prepare-flashgrep-resource.mjs --target "${{ matrix.platform.target }}" + - name: Build desktop app run: ${{ matrix.platform.build_command }} diff --git a/.gitignore b/.gitignore index d8159bba7b..9230dffc5b 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,7 @@ src/apps/mobile/android/.kotlin/ /src/apps/mobile/ios/build/ .openbitfun/ .bitfun/ + +# Downloaded Flashgrep release assets (metadata stays tracked). +/resources/flashgrep/flashgrep-* +/resources/flashgrep/*.download-* diff --git a/OpenBitFun-Installer/scripts/build-installer.cjs b/OpenBitFun-Installer/scripts/build-installer.cjs index 20bd2776a4..1f69140786 100644 --- a/OpenBitFun-Installer/scripts/build-installer.cjs +++ b/OpenBitFun-Installer/scripts/build-installer.cjs @@ -33,6 +33,7 @@ const REQUIRED_PAYLOAD_FILES = [ "mobile-web/dist/index.html", "resources/ext-host/extension-host.js", "resources/worker_host.js", + "flashgrep/flashgrep-x86_64-pc-windows-msvc.exe", ]; function getMode(args) { @@ -288,6 +289,7 @@ if (appExePath) { "swiftshader", "mobile-web", "frontend", + "flashgrep", ]; for (const dirName of runtimeDirs) { const srcDir = path.join(releaseDir, dirName); diff --git a/OpenBitFun-Installer/src-tauri/build.rs b/OpenBitFun-Installer/src-tauri/build.rs index 88045a3664..424b09f506 100644 --- a/OpenBitFun-Installer/src-tauri/build.rs +++ b/OpenBitFun-Installer/src-tauri/build.rs @@ -5,13 +5,14 @@ use std::path::{Path, PathBuf}; use zip::write::FileOptions; use zip::{CompressionMethod, ZipWriter}; -const REQUIRED_PAYLOAD_FILES: [&str; 6] = [ +const REQUIRED_PAYLOAD_FILES: [&str; 7] = [ "openbitfun-desktop.exe", "openbitfun-data-migrator.exe", "frontend/dist/index.html", "mobile-web/dist/index.html", "resources/ext-host/extension-host.js", "resources/worker_host.js", + "flashgrep/flashgrep-x86_64-pc-windows-msvc.exe", ]; fn main() { diff --git a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs index b53102c14e..78f7796cd9 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs @@ -48,13 +48,14 @@ struct WindowsInstallState { const MIN_WINDOWS_APP_EXE_BYTES: u64 = 5 * 1024 * 1024; const PAYLOAD_MANIFEST_FILE: &str = "payload-manifest.json"; -const REQUIRED_PAYLOAD_FILES: [&str; 6] = [ +const REQUIRED_PAYLOAD_FILES: [&str; 7] = [ MAIN_APP_EXE, DATA_MIGRATOR_EXE, "frontend/dist/index.html", "mobile-web/dist/index.html", "resources/ext-host/extension-host.js", "resources/worker_host.js", + "flashgrep/flashgrep-x86_64-pc-windows-msvc.exe", ]; const INSTALLER_STATE_FILE: &str = "installer-state.json"; #[cfg(target_os = "windows")] diff --git a/docs/features/remote-workspaces.md b/docs/features/remote-workspaces.md index 1fa87de482..5c03cbb911 100644 --- a/docs/features/remote-workspaces.md +++ b/docs/features/remote-workspaces.md @@ -1,5 +1,10 @@ # Remote SSH, container, and WSL workspaces +Flashgrep accelerated search is currently unavailable for remote workspaces. +Index controls are hidden; content-search operations that depend on Flashgrep +return an explicit unsupported error. Remote file-name search remains available. +OpenBitFun never falls back to searching the controller filesystem for a remote path. + OpenBitFun remote workspaces use one saved target for the file explorer, terminal, Agent commands, and workspace tools. The target can be: diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 04b6121d30..76a539eb14 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -8401,7 +8401,7 @@ "rustPath": "search_build_index", "sourceFile": "src/apps/desktop/src/api/search_api.rs", "signature": "fn search_build_index( state: State<'_, AppState>, request: SearchRepoIndexRequest, ) -> Result", - "remoteWorkspacePolicy": "RemoteRouted" + "remoteWorkspacePolicy": "RemoteUnsupported" }, { "id": "search_file_contents", @@ -8465,7 +8465,7 @@ "rustPath": "search_get_repo_status", "sourceFile": "src/apps/desktop/src/api/search_api.rs", "signature": "fn search_get_repo_status( state: State<'_, AppState>, request: SearchRepoIndexRequest, ) -> Result", - "remoteWorkspacePolicy": "RemoteRouted" + "remoteWorkspacePolicy": "RemoteUnsupported" }, { "id": "search_rebuild_index", @@ -8483,7 +8483,7 @@ "rustPath": "search_rebuild_index", "sourceFile": "src/apps/desktop/src/api/search_api.rs", "signature": "fn search_rebuild_index( state: State<'_, AppState>, request: SearchRepoIndexRequest, ) -> Result", - "remoteWorkspacePolicy": "RemoteRouted" + "remoteWorkspacePolicy": "RemoteUnsupported" }, { "id": "search_referenceable_sessions", diff --git a/resources/flashgrep/README.md b/resources/flashgrep/README.md index 9ec6f03b5e..06f9ed230f 100644 --- a/resources/flashgrep/README.md +++ b/resources/flashgrep/README.md @@ -1,22 +1,37 @@ -Flashgrep distribution is temporarily suspended. The platform binaries have been removed, -and desktop development, packaging, and the Windows installer no longer require them. -The Web UI hides accelerated-search settings and index controls, including on remote -workspaces and peer devices. Saved preferences and backend implementations are retained. +# Flashgrep binary distribution -To restore the feature, restore the binaries and desktop preparation/bundling steps, -then enable `WORKSPACE_SEARCH_AVAILABLE` in the Web UI. +Desktop development and packaging download the pinned release from +https://github.com/wgqqqqq/flashgrep-binaries/releases. No binaries belong in Git. +`VERSION.json` pins the repository, tag, filename, size, and SHA-256 for every target. -Pinned release: +Run `node scripts/prepare-flashgrep-resource.mjs` for the current host, or pass +`--target x86_64-pc-windows-msvc` (including `--target=...` syntax). Desktop builds +use the explicit Tauri target, falling back to the Rust host triple. Linux GNU +application targets use the matching musl Flashgrep binary. Unsupported targets +fail explicitly. The preparation step requires curl (curl.exe on Windows). -- `v0.2.16` from `wgqqqqq/flashgrep` +Downloads go to ignored files in this directory. Cached files are verified before +reuse; failed or corrupted downloads never become package inputs. Only the +selected binary is added to Tauri resources, even if the cache holds other targets. +macOS release builds sign a copy with APPLE_SIGNING_IDENTITY, preserving the +original download and its checksum. The Windows custom installer currently targets +x86_64 and requires its matching Flashgrep payload. -Expected filenames (restoration reference): +Remote workspaces do not support Flashgrep. Index controls are hidden and remote +index/content/glob service calls return an explicit unsupported error before SSH +or local filesystem access. Remote file-name search and agent search paths that +already use remote shell tools remain available. Saved preferences are retained. -- macOS x86_64: `flashgrep-x86_64-apple-darwin` -- macOS arm64: `flashgrep-aarch64-apple-darwin` -- Linux x86_64: `flashgrep-x86_64-unknown-linux-musl` -- Linux arm64: `flashgrep-aarch64-unknown-linux-musl` -- Windows x86_64: `flashgrep-x86_64-pc-windows-msvc.exe` -- Windows arm64: `flashgrep-aarch64-pc-windows-msvc.exe` +## Updating the pinned release -macOS binaries are ad-hoc signed after download so local development can execute them directly. +Publish the six standalone binaries and SHA256SUMS to the public binary repository +as Release assets, without publishing private source or MCP bundles. Verify the +release, then update VERSION.json with its tag and asset checksums/sizes. Do not use +`latest` or replace assets of an already consumed release; publish a new version. +OpenBitFun builds only need public download access, with no private-repository token. + +## Focused verification + +```sh +node --test scripts/prepare-flashgrep-resource.test.mjs scripts/desktop-tauri-build.test.mjs OpenBitFun-Installer/scripts/build-installer.test.cjs +``` diff --git a/resources/flashgrep/VERSION.json b/resources/flashgrep/VERSION.json index 118fbb35fc..4f906872f0 100644 --- a/resources/flashgrep/VERSION.json +++ b/resources/flashgrep/VERSION.json @@ -1,5 +1,30 @@ { - "repo": "wgqqqqq/flashgrep", + "repo": "wgqqqqq/flashgrep-binaries", "tag": "v0.2.16", - "published_at": "2026-09-01T09:14:49Z" + "assets": { + "flashgrep-aarch64-apple-darwin": { + "sha256": "f35051821426b51409afdf13f927ce3e3270ef9eb938c5f60d6bbafa09cbc783", + "size": 8835776 + }, + "flashgrep-aarch64-pc-windows-msvc.exe": { + "sha256": "a6dc3912f7fd65428a68a5a061179c7dd951531aa6db176dadfb651519e36cc6", + "size": 7029760 + }, + "flashgrep-aarch64-unknown-linux-musl": { + "sha256": "2702a4f3e34c45e9e322c6ac5b0a842a789a617d41b15241117f5097889f907c", + "size": 9953280 + }, + "flashgrep-x86_64-apple-darwin": { + "sha256": "f81bf953eb8680ce896b66b997cff8ade25681b0f9b6c290b8130c57f035a14f", + "size": 9423968 + }, + "flashgrep-x86_64-pc-windows-msvc.exe": { + "sha256": "96684c3a946d90a42d3cfca3a7c511a41422eb7e0cb702801179ba363d9d6ed0", + "size": 8240128 + }, + "flashgrep-x86_64-unknown-linux-musl": { + "sha256": "e09eb8c187512c3a29eea89126ba814e411d53417af5c0cfd68a28f253a2bcc4", + "size": 10638320 + } + } } diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 34ca961ce6..e961ccac0e 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -13,6 +13,7 @@ import { statSync, writeFileSync, } from 'fs'; +import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; @@ -24,12 +25,6 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); -const LINUX_FLASHGREP_BINARIES = [ - 'flashgrep-x86_64-unknown-linux-musl', - 'flashgrep-x86_64-unknown-linux-gnu', - 'flashgrep-aarch64-unknown-linux-musl', - 'flashgrep-aarch64-unknown-linux-gnu', -]; function tauriBuildArgsFromArgv() { const args = process.argv.slice(2); @@ -53,8 +48,11 @@ async function main() { const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); preparePluginHost(); - // Flashgrep distribution is temporarily suspended. - const flashgrepBinary = null; + const flashgrepBinary = prepareMacOSFlashgrepForSigning( + ensureFlashgrepBinary({ target: optionValue(forward, '--target') || rustHostTargetTriple() }), + desktopDir, + ); + process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). process.env.CI = 'true'; if (process.platform === 'darwin' && requestsDmgBundle(forward)) { @@ -112,7 +110,7 @@ async function main() { if (r.status === 0 && forward.includes('--no-bundle')) { console.warn( - '[tauri-build] No bundle was produced. The raw desktop executable depends on its adjacent frontend, mobile-web, and resources directories and must not be distributed by itself.' + '[tauri-build] No bundle was produced. The raw desktop executable depends on its adjacent frontend, flashgrep, mobile-web, and resources directories and must not be distributed by itself.' ); } @@ -280,7 +278,7 @@ export function prepareMacOSFlashgrepForSigning( '--timestamp', signedBinary, ], - { encoding: 'utf8', shell: false }, + { encoding: 'utf8', shell: false, windowsHide: true }, ); if (result.error || result.status !== 0) { const detail = result.error?.message || result.stderr || `exit status ${result.status}`; @@ -409,19 +407,7 @@ function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { } function bundledFlashgrepResources(primaryBinary) { - if (!primaryBinary) return []; - const binaries = [primaryBinary]; - - if (process.platform === 'win32') { - for (const binaryName of LINUX_FLASHGREP_BINARIES) { - const binaryPath = join(ROOT, 'resources', 'flashgrep', binaryName); - if (existsSync(binaryPath)) { - binaries.push(binaryPath); - } - } - } - - return [...new Set(binaries)]; + return primaryBinary ? [primaryBinary] : []; } function toTauriPath(value) { diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 2399ecc306..6b5b9eab3a 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -325,7 +325,7 @@ test('Desktop Tauri projection consumes only the resolved member identity', () = } }); -test('Desktop packaging works without the suspended Flashgrep resource', () => { +test('Desktop packaging includes only the selected Flashgrep target', () => { const fixture = join(tmpdir(), `openbitfun-without-flashgrep-${process.pid}-${Date.now()}`); mkdirSync(fixture, { recursive: true }); const baseConfig = join(fixture, 'tauri.conf.json'); @@ -333,10 +333,15 @@ test('Desktop packaging works without the suspended Flashgrep resource', () => { bundle: { resources: { '../../../resources/flashgrep': 'flashgrep' } }, })); try { - const generated = prepareTauriConfig(baseConfig, { desktopDir: fixture }); + const generated = prepareTauriConfig(baseConfig, { + desktopDir: fixture, flashgrepBinary: join(fixture, 'flashgrep-aarch64-apple-darwin'), + }); const config = JSON.parse(readFileSync(generated, 'utf8')); - assert.ok(Object.entries(config.bundle.resources).every(([source, target]) => - !source.includes('flashgrep') && !target.includes('flashgrep'))); + assert.deepEqual(Object.entries(config.bundle.resources).filter(([, target]) => + target.startsWith('flashgrep/')), [ + ['flashgrep-aarch64-apple-darwin', 'flashgrep/flashgrep-aarch64-apple-darwin'], + ]); + assert.equal(config.bundle.resources['../../../resources/flashgrep'], undefined); assert.equal(config.bundle.resources['../../../dist'], 'frontend/dist'); } finally { rmSync(fixture, { force: true, recursive: true }); diff --git a/scripts/dev-server-ports.mjs b/scripts/dev-server-ports.mjs new file mode 100644 index 0000000000..b786381d00 --- /dev/null +++ b/scripts/dev-server-ports.mjs @@ -0,0 +1,9 @@ +export function resolveDevServerPorts(env = process.env) { + const port = Number(env.OPENBITFUN_DEV_PORT || 1422); + const hmrPort = Number(env.OPENBITFUN_DEV_HMR_PORT || port - 1); + if (![port, hmrPort].every(value => Number.isInteger(value) && value >= 1024 && value <= 65535) + || port === hmrPort) { + throw new Error('Development HTTP and HMR ports must be distinct integers between 1024 and 65535'); + } + return { port, hmrPort }; +} diff --git a/scripts/dev-startup.test.mjs b/scripts/dev-startup.test.mjs new file mode 100644 index 0000000000..16aa1081e4 --- /dev/null +++ b/scripts/dev-startup.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { resolveDevServerPorts } from './dev-server-ports.mjs'; +import { prepareSherpaDev } from './prepare-sherpa-dev.mjs'; + +test('HTTP and HMR ports share one contract and reject conflicting overrides', () => { + assert.deepEqual(resolveDevServerPorts({}), { port: 1422, hmrPort: 1421 }); + assert.deepEqual(resolveDevServerPorts({ OPENBITFUN_DEV_PORT: '1432' }), { port: 1432, hmrPort: 1431 }); + assert.deepEqual(resolveDevServerPorts({ OPENBITFUN_DEV_PORT: '1432', OPENBITFUN_DEV_HMR_PORT: '1440' }), { port: 1432, hmrPort: 1440 }); + for (const port of ['abc', '0', '65536', '1.5']) { + assert.throws(() => resolveDevServerPorts({ OPENBITFUN_DEV_PORT: port }), /ports/); + } + assert.throws(() => resolveDevServerPorts({ OPENBITFUN_DEV_PORT: '1432', OPENBITFUN_DEV_HMR_PORT: '1432' }), /distinct/); +}); + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'sherpa-dev-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + writeFileSync(join(root, 'Cargo.lock'), '[[package]]\nname = "sherpa-onnx-sys"\nversion = "1.13.4"\n'); + return root; +} +const target = 'aarch64-apple-darwin'; +const archive = 'sherpa-onnx-v1.13.4-osx-arm64-static-lib.tar.bz2'; + +test('explicit library and archive paths are preserved', () => { + for (const key of ['SHERPA_ONNX_LIB_DIR', 'SHERPA_ONNX_ARCHIVE_DIR']) { + const env = { [key]: '/explicit' }; + prepareSherpaDev('/unused', env, { run: () => assert.fail('must not run commands') }); + assert.deepEqual(env, { [key]: '/explicit' }); + } +}); + +test('a worktree reuses the matching main checkout library without downloading', t => { + const root = fixture(t); + const main = join(root, 'main'); + const library = join(main, 'target/sherpa-onnx-prebuilt', archive.replace('.tar.bz2', ''), 'lib'); + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, 'libsherpa-onnx-c-api.a'), 'fixture'); + const env = {}; + prepareSherpaDev(root, env, { target, run(command) { + assert.equal(command, 'git'); + return { status: 0, stdout: join(main, '.git') }; + } }); + assert.equal(env.SHERPA_ONNX_LIB_DIR, library); +}); + +test('downloads the locked archive through curl and reuses it on the next run', t => { + const root = fixture(t); + let downloads = 0; + const run = (command, args, options) => { + if (command === 'git') return { status: 1 }; + assert.match(command, /^curl(?:\.exe)?$/); + assert.equal(options.windowsHide, true); + assert.equal(args.at(-1), `https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.4/${archive}`); + writeFileSync(args[args.indexOf('--output') + 1], 'archive fixture'); + downloads++; + return { status: 0 }; + }; + for (let i = 0; i < 2; i++) { + const env = {}; + prepareSherpaDev(root, env, { target, run }); + assert.equal(readFileSync(join(env.SHERPA_ONNX_ARCHIVE_DIR, archive), 'utf8'), 'archive fixture'); + } + assert.equal(downloads, 1); +}); + +test('failed downloads are not published as reusable archives', t => { + const root = fixture(t); + const env = {}; + assert.throws(() => prepareSherpaDev(root, env, { target, run(command) { + return command === 'git' ? { status: 1 } : { status: 22, stderr: 'download failed' }; + } }), /download failed/); + assert.equal(env.SHERPA_ONNX_ARCHIVE_DIR, undefined); +}); diff --git a/scripts/dev.cjs b/scripts/dev.cjs index bcfd15567b..46387f1c1f 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -21,7 +21,7 @@ const { printBlank, } = require('./console-style.cjs'); const ROOT_DIR = path.resolve(__dirname, '..'); -const DEV_SERVER_PORT = 1422; +let DEV_SERVER_PORT = 1422; const DEV_SERVER_HOSTS = ['localhost', '127.0.0.1', '::1']; const DESKTOP_PREVIEW_REBUILD_INPUTS = [ path.join(ROOT_DIR, 'Cargo.toml'), @@ -587,6 +587,8 @@ async function startDesktopPreview() { * Main entry */ async function main() { + const { resolveDevServerPorts } = await import('./dev-server-ports.mjs'); + DEV_SERVER_PORT = resolveDevServerPorts().port; const startTime = Date.now(); let mode = process.argv[2] || 'web'; // web | desktop const extraArgs = process.argv.slice(3); @@ -612,7 +614,7 @@ async function main() { let currentStep = 1; // Step 1: Run all independent preparation tasks in parallel. - // copy-monaco / generate-version / mobile-web / plugin-host have no + // copy-monaco / generate-version / mobile-web / flashgrep / plugin-host have no // dependencies on each other; each task's output is line-prefixed so the // interleaved logs stay attributable. The DeepSeek bridge is not prepared // here: it is not a compile-time Tauri resource. Official desktop:build @@ -621,7 +623,7 @@ async function main() { currentStep++, totalSteps, desktopMode - ? 'Prepare resources (parallel: monaco, version, mobile-web, plugin-host)' + ? 'Prepare resources (parallel: monaco, version, mobile-web, flashgrep, plugin-host)' : 'Prepare resources (parallel: monaco, version)' ); @@ -647,6 +649,32 @@ async function main() { hint: 'Hint: install Bun, then run `pnpm run plugin-host:prepare`', promise: runCommandPrefixed('plugin-host', 'pnpm', ['run', 'plugin-host:prepare']), }); + prepTasks.push({ + name: 'Prepare speech libraries', + promise: (async () => { + try { + const { prepareSherpaDev } = await import('./prepare-sherpa-dev.mjs'); + prepareSherpaDev(ROOT_DIR); + return { ok: true, code: 0 }; + } catch (error) { + return { ok: false, code: null, error }; + } + })(), + }); + prepTasks.push({ + name: 'Prepare workspace search daemon', + promise: (async () => { + try { + const { ensureFlashgrepBinary } = await import( + pathToFileURL(path.join(__dirname, 'prepare-flashgrep-resource.mjs')).href + ); + process.env.FLASHGREP_DAEMON_BIN = ensureFlashgrepBinary(); + return { ok: true, code: 0 }; + } catch (error) { + return { ok: false, code: null, error }; + } + })(), + }); prepTasks.push({ name: 'Build mobile-web', promise: runCommandPrefixed('mobile-web', 'node', ['scripts/mobile-web-build.cjs', '--install']), @@ -734,15 +762,16 @@ async function main() { OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'), }; try { + const args = ['dev', '--config', tauriConfig, '--config', JSON.stringify({ build: { devUrl: `http://localhost:${DEV_SERVER_PORT}` } })]; if (process.platform === 'win32') { // Running the generated .cmd shim directly via spawn is flaky on Windows. // Use cmd.exe with an explicit args array so the desktop app directory // stays the Tauri project root without pnpm workspace path rewriting. const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd'); - await runWindowsCommandArgs(tauriBin, ['dev', '--config', tauriConfig], desktopDir, tauriDevEnv); + await runWindowsCommandArgs(tauriBin, args, desktopDir, tauriDevEnv); } else { const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri'); - await spawnCommand(tauriBin, ['dev', '--config', tauriConfig], desktopDir, { + await spawnCommand(tauriBin, args, desktopDir, { CARGO_PROFILE_DEV_CODEGEN_UNITS: tauriDevEnv.CARGO_PROFILE_DEV_CODEGEN_UNITS, OPENBITFUN_MOBILE_WEB_DIR: tauriDevEnv.OPENBITFUN_MOBILE_WEB_DIR, }); diff --git a/scripts/prepare-dsh-profile.test.mjs b/scripts/prepare-dsh-profile.test.mjs index 51ebae240f..d602c55516 100644 --- a/scripts/prepare-dsh-profile.test.mjs +++ b/scripts/prepare-dsh-profile.test.mjs @@ -155,7 +155,7 @@ test('desktop:dev does not compile the DeepSeek profile', () => { assert.doesNotMatch(devScript, /\['run', 'prepare:dsh-profile'\]/); assert.match( devScript, - /Prepare resources \(parallel: monaco, version, mobile-web, plugin-host\)/, + /Prepare resources \(parallel: monaco, version, mobile-web, flashgrep, plugin-host\)/, ); }); diff --git a/scripts/prepare-flashgrep-resource.mjs b/scripts/prepare-flashgrep-resource.mjs index cb3b1b9d85..ffbe9de275 100644 --- a/scripts/prepare-flashgrep-resource.mjs +++ b/scripts/prepare-flashgrep-resource.mjs @@ -1,66 +1,88 @@ -import { chmodSync, existsSync, statSync } from 'fs'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; +import { createHash, randomUUID } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..'); +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const RESOURCE_DIR = join(ROOT, 'resources', 'flashgrep'); +const HOST_TARGETS = { + 'darwin/x64': 'x86_64-apple-darwin', + 'darwin/arm64': 'aarch64-apple-darwin', + 'win32/x64': 'x86_64-pc-windows-msvc', + 'win32/arm64': 'aarch64-pc-windows-msvc', + 'linux/x64': 'x86_64-unknown-linux-musl', + 'linux/arm64': 'aarch64-unknown-linux-musl', +}; -export function flashgrepBinaryNames() { - if (process.platform === 'win32' && process.arch === 'x64') { - return ['flashgrep-x86_64-pc-windows-msvc.exe']; +export function flashgrepBinaryNames({ target, platform = process.platform, arch = process.arch } = {}) { + const triple = (target || HOST_TARGETS[`${platform}/${arch}`] || '') + .replace(/-unknown-linux-gnu$/, '-unknown-linux-musl'); + if (!Object.values(HOST_TARGETS).includes(triple)) { + throw new Error(`Unsupported Flashgrep target: ${target || `${platform}/${arch}`}`); } - if (process.platform === 'win32' && process.arch === 'arm64') { - return ['flashgrep-aarch64-pc-windows-msvc.exe']; - } - if (process.platform === 'darwin' && process.arch === 'x64') { - return ['flashgrep-x86_64-apple-darwin']; - } - if (process.platform === 'darwin' && process.arch === 'arm64') { - return ['flashgrep-aarch64-apple-darwin']; - } - if (process.platform === 'linux' && process.arch === 'x64') { - return [ - 'flashgrep-x86_64-unknown-linux-musl', - 'flashgrep-x86_64-unknown-linux-gnu', - ]; - } - if (process.platform === 'linux' && process.arch === 'arm64') { - return [ - 'flashgrep-aarch64-unknown-linux-musl', - 'flashgrep-aarch64-unknown-linux-gnu', - ]; - } - return [process.platform === 'win32' ? 'flashgrep.exe' : 'flashgrep']; + return [`flashgrep-${triple}${triple.includes('windows') ? '.exe' : ''}`]; } -export function flashgrepBinaryName() { - return flashgrepBinaryNames()[0]; +export function flashgrepBinaryName(options) { + return flashgrepBinaryNames(options)[0]; } -export function flashgrepBinaryPath() { - const availableBinaryName = - flashgrepBinaryNames().find((binaryName) => existsSync(join(RESOURCE_DIR, binaryName))) ?? - flashgrepBinaryName(); - return join(RESOURCE_DIR, availableBinaryName); +export function flashgrepBinaryPath(options = {}) { + return join(options.resourceDir || RESOURCE_DIR, flashgrepBinaryName(options)); } -export function ensureFlashgrepBinary() { - for (const binaryName of flashgrepBinaryNames()) { - const binaryPath = join(RESOURCE_DIR, binaryName); - if (!existsSync(binaryPath)) { - continue; - } +export function downloadFlashgrep(url, destination) { + // curl supports system proxy settings on all desktop build hosts, including Windows. + const result = spawnSync(process.platform === 'win32' ? 'curl.exe' : 'curl', [ + '--fail', '--location', '--silent', '--show-error', + '--proto', '=https', '--proto-redir', '=https', + '--retry', '3', '--connect-timeout', '20', '--max-time', '180', + '--output', destination, url, + ], { encoding: 'utf8', windowsHide: true, shell: false }); + if (result.error || result.status !== 0) { + throw new Error(`Failed to download Flashgrep: ${result.error?.message || result.stderr || result.status}`); + } +} - if (process.platform !== 'win32') { - chmodSync(binaryPath, statSync(binaryPath).mode | 0o111); +export function ensureFlashgrepBinary(options = {}) { + const resourceDir = options.resourceDir || RESOURCE_DIR; + const manifest = options.manifest || JSON.parse(readFileSync(join(RESOURCE_DIR, 'VERSION.json'), 'utf8')); + const name = flashgrepBinaryName(options); + const asset = manifest.assets?.[name]; + if (!asset || !/^[a-f0-9]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.size) || asset.size <= 0) { + throw new Error(`Missing or invalid pinned Flashgrep asset: ${name}`); + } + if (!/^[\w.-]+\/[\w.-]+$/.test(manifest.repo) || !/^v[\w.-]+$/.test(manifest.tag)) { + throw new Error('Invalid pinned Flashgrep release'); + } + const binaryPath = join(resourceDir, name); + const valid = (path) => { + if (!existsSync(path)) return false; + const bytes = readFileSync(path); + return bytes.length === asset.size && createHash('sha256').update(bytes).digest('hex') === asset.sha256; + }; + if (!valid(binaryPath)) { + mkdirSync(resourceDir, { recursive: true }); + const temporary = `${binaryPath}.download-${randomUUID()}`; + try { + const url = `https://github.com/${manifest.repo}/releases/download/${manifest.tag}/${name}`; + (options.download || downloadFlashgrep)(url, temporary); + if (!valid(temporary)) throw new Error(`Flashgrep SHA-256 or size mismatch: ${name}`); + renameSync(temporary, binaryPath); + } finally { + rmSync(temporary, { force: true }); } - return binaryPath; } + if (!name.endsWith('.exe')) chmodSync(binaryPath, 0o755); + return binaryPath; +} - throw new Error( - `flashgrep binary not found for ${process.platform}/${process.arch}. Expected one of: ${flashgrepBinaryNames() - .map((name) => `resources/flashgrep/${name}`) - .join(', ')}` - ); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const inlineTarget = args.find((arg) => arg.startsWith('--target=')); + const targetIndex = args.indexOf('--target'); + const target = inlineTarget ? inlineTarget.slice('--target='.length) : targetIndex >= 0 ? args[targetIndex + 1] : undefined; + if (targetIndex >= 0 && !target) throw new Error('--target requires a Rust target triple'); + console.log(ensureFlashgrepBinary({ target })); } diff --git a/scripts/prepare-flashgrep-resource.test.mjs b/scripts/prepare-flashgrep-resource.test.mjs new file mode 100644 index 0000000000..6fab486077 --- /dev/null +++ b/scripts/prepare-flashgrep-resource.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { ensureFlashgrepBinary, flashgrepBinaryName } from './prepare-flashgrep-resource.mjs'; + +for (const [target, name] of [ + ['x86_64-apple-darwin', 'flashgrep-x86_64-apple-darwin'], + ['aarch64-apple-darwin', 'flashgrep-aarch64-apple-darwin'], + ['x86_64-pc-windows-msvc', 'flashgrep-x86_64-pc-windows-msvc.exe'], + ['aarch64-pc-windows-msvc', 'flashgrep-aarch64-pc-windows-msvc.exe'], + ['x86_64-unknown-linux-gnu', 'flashgrep-x86_64-unknown-linux-musl'], + ['aarch64-unknown-linux-gnu', 'flashgrep-aarch64-unknown-linux-musl'], +]) { + test(`selects ${target} independently of the build host`, () => { + assert.equal(flashgrepBinaryName({ target, platform: 'darwin', arch: 'arm64' }), name); + }); +} + +test('unsupported targets fail explicitly', () => { + assert.throws(() => flashgrepBinaryName({ target: 'riscv64-unknown-linux-gnu' }), /Unsupported/); + assert.throws(() => flashgrepBinaryName({ platform: 'linux', arch: 'ia32' }), /Unsupported/); +}); + +test('verified downloads are cached, corrupt caches are repaired, invalid downloads never replace assets', () => { + const resourceDir = mkdtempSync(join(tmpdir(), 'flashgrep-download-')); + const target = 'aarch64-apple-darwin'; + const name = flashgrepBinaryName({ target }); + const bytes = Buffer.from('verified binary'); + const manifest = { repo: 'wgqqqqq/flashgrep-binaries', tag: 'v0.2.16', assets: { + [name]: { sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length }, + } }; + let downloads = 0; + const options = { resourceDir, target, manifest, download(url, destination) { + downloads++; + assert.equal(url, `https://github.com/wgqqqqq/flashgrep-binaries/releases/download/v0.2.16/${name}`); + writeFileSync(destination, bytes); + } }; + try { + const path = ensureFlashgrepBinary(options); + assert.deepEqual(readFileSync(path), bytes); + assert.equal(ensureFlashgrepBinary(options), path); + assert.equal(downloads, 1); + writeFileSync(path, 'corrupt'); + ensureFlashgrepBinary(options); + assert.equal(downloads, 2); + writeFileSync(path, 'old content'); + assert.throws(() => ensureFlashgrepBinary({ ...options, + download: (_, destination) => writeFileSync(destination, 'bad release'), + }), /mismatch/); + assert.equal(readFileSync(path, 'utf8'), 'old content'); + assert.deepEqual(readdirSync(resourceDir), [name]); + assert.throws(() => ensureFlashgrepBinary({ ...options, manifest: { ...manifest, assets: {} } }), /Missing/); + assert.throws(() => ensureFlashgrepBinary({ ...options, + download: () => { throw new Error('offline'); }, + }), /offline/); + assert.deepEqual(readdirSync(resourceDir), [name]); + } finally { + rmSync(resourceDir, { recursive: true, force: true }); + } +}); diff --git a/scripts/prepare-sherpa-dev.mjs b/scripts/prepare-sherpa-dev.mjs new file mode 100644 index 0000000000..8c643735c1 --- /dev/null +++ b/scripts/prepare-sherpa-dev.mjs @@ -0,0 +1,60 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; + +const ARCHIVES = { + 'aarch64-apple-darwin': 'osx-arm64-static-lib', + 'x86_64-apple-darwin': 'osx-x64-static-lib', + 'x86_64-unknown-linux-gnu': 'linux-x64-static-lib', + 'aarch64-unknown-linux-gnu': 'linux-aarch64-static-lib', + 'x86_64-pc-windows-msvc': 'win-x64-static-MT-Release-lib', +}; + +export function prepareSherpaDev(root, env = process.env, runtime = {}) { + if (env.SHERPA_ONNX_LIB_DIR || env.SHERPA_ONNX_ARCHIVE_DIR) return; + const run = runtime.run || spawnSync; + const commandOptions = { cwd: root, encoding: 'utf8', windowsHide: true, shell: false }; + const target = runtime.target || env.CARGO_BUILD_TARGET + || String(run('rustc', ['-vV'], commandOptions).stdout).match(/^host:\s*(\S+)$/m)?.[1]; + if (!ARCHIVES[target]) throw new Error(`Unsupported Sherpa development target: ${target}`); + const version = readFileSync(join(root, 'Cargo.lock'), 'utf8') + .match(/name = "sherpa-onnx-sys"\r?\nversion = "([\d.]+)"/)?.[1]; + if (!version) throw new Error('Cannot find the locked sherpa-onnx-sys version'); + const stem = `sherpa-onnx-v${version}-${ARCHIVES[target]}`; + const archive = `${stem}.tar.bz2`; + const cache = join(resolve(root, env.CARGO_TARGET_DIR || 'target'), 'sherpa-onnx-prebuilt'); + const common = run('git', ['rev-parse', '--git-common-dir'], commandOptions); + const sharedRoot = common.status === 0 ? dirname(resolve(root, common.stdout.trim())) : root; + for (const directory of [cache, join(sharedRoot, 'target', 'sherpa-onnx-prebuilt')]) { + const library = join(directory, stem, 'lib'); + const libraryName = target.includes('windows') ? 'sherpa-onnx-c-api.lib' : 'libsherpa-onnx-c-api.a'; + if (existsSync(join(library, libraryName))) { + env.SHERPA_ONNX_LIB_DIR = library; + return; + } + if (existsSync(join(directory, archive))) { + env.SHERPA_ONNX_ARCHIVE_DIR = directory; + return; + } + } + // Download through curl, which supports the user's HTTP and SOCKS proxies. + // sherpa-onnx-sys retains ownership of archive extraction and linking. + mkdirSync(cache, { recursive: true }); + const temporary = join(cache, `${archive}.download-${randomUUID()}`); + try { + const result = run(process.platform === 'win32' ? 'curl.exe' : 'curl', [ + '--fail', '--location', '--silent', '--show-error', '--proto', '=https', '--proto-redir', '=https', + '--retry', '3', '--connect-timeout', '20', '--max-time', '300', + '--output', temporary, + `https://github.com/k2-fsa/sherpa-onnx/releases/download/v${version}/${archive}`, + ], commandOptions); + if (result.error || result.status !== 0) { + throw new Error(`Failed to prepare Sherpa libraries: ${result.error?.message || result.stderr || result.status}`); + } + renameSync(temporary, join(cache, archive)); + env.SHERPA_ONNX_ARCHIVE_DIR = cache; + } finally { + rmSync(temporary, { force: true }); + } +} diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 264656fb96..9bd77d5bfa 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -79,7 +79,7 @@ required. The default dev profile keeps line tables while reducing PDB size. All commands that pass `--no-bundle` emit a staged runtime tree rather than a single-file application. The executable depends on the adjacent `frontend`, -`mobile-web`, and `resources` directories. Use +`flashgrep`, `mobile-web`, and `resources` directories. Use `pnpm run desktop:build:nsis` for a distributable Windows installer. ## DevTools feature (model rule) @@ -126,3 +126,11 @@ concurrent builds cannot replace its lazy modules. It uses temporary product sto the private test store intentionally does not survive process exit. That debug-only switch takes effect only with the existing E2E storage guard; release builds always use the packaged protocol. + +For alternate dev-server ports and preview startup URL changes, run +`node --test scripts/dev-startup.test.mjs` and +`cargo test -p openbitfun-desktop --no-default-features --lib appearance::development_frontend_tests`. +`OPENBITFUN_DEV_PORT` selects the HTTP port; `OPENBITFUN_DEV_HMR_PORT` defaults +to the previous port. Desktop and Vite must use the same values. Development +launchers reuse the locked Sherpa library/archive cache across Git worktrees, +or download the archive through curl when absent; explicit SHERPA_ONNX overrides win. diff --git a/src/apps/desktop/README.md b/src/apps/desktop/README.md index d7056ea473..664bbb33e1 100644 --- a/src/apps/desktop/README.md +++ b/src/apps/desktop/README.md @@ -65,3 +65,22 @@ configuration, or OS limits automatically. If the problem persists after the fix, capture the OpenBitFun version and logs plus the server's SFTP implementation and advertised limits so genuine concurrent-handle or server resource exhaustion can be distinguished from a client lifecycle problem. + +## Development startup + +Run `pnpm run desktop:dev` from the repository root. The launcher prepares +Flashgrep and the locked Sherpa speech libraries before compiling Desktop. +Sherpa libraries or archives are reused from the current target cache or the +main Git checkout's target cache. If absent, curl downloads the version-specific +archive, supporting HTTP and SOCKS proxies; Cargo handles extraction and linking. +Explicit `SHERPA_ONNX_LIB_DIR` and `SHERPA_ONNX_ARCHIVE_DIR` overrides are preserved. + +When another worktree uses the default ports, start a separate dev server: + +```sh +OPENBITFUN_DEV_PORT=1432 pnpm run desktop:dev +``` + +HMR uses port 1431 in this example; `OPENBITFUN_DEV_HMR_PORT` can override it. +The launcher supplies the same HTTP URL to Tauri that Vite listens on, and both +the main window and companion window read that configured URL. diff --git a/src/apps/desktop/src/api/search_api.rs b/src/apps/desktop/src/api/search_api.rs index efa8524ef9..bc2c216030 100644 --- a/src/apps/desktop/src/api/search_api.rs +++ b/src/apps/desktop/src/api/search_api.rs @@ -1,8 +1,6 @@ use crate::api::app_state::AppState; use openbitfun_core::infrastructure::{FileSearchResult, FileSearchResultGroup, SearchMatchType}; -use openbitfun_core::service::remote_ssh::workspace_state::{ - is_remote_path, lookup_remote_connection, -}; +use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; use openbitfun_core::service::search::{ remote_workspace_search_service_for_path, workspace_search_daemon_available, workspace_search_feature_enabled, ContentSearchRequest, ContentSearchResult, @@ -94,21 +92,11 @@ fn repo_status_error_message(error: impl std::fmt::Display) -> String { } async fn workspace_search_unavailable_message( - state: &State<'_, AppState>, + _state: &State<'_, AppState>, root_path: &str, ) -> Option { if is_remote_path(root_path.trim()).await { - if lookup_remote_connection(root_path.trim()).await.is_none() { - return Some( - "Remote workspace is not registered with OpenBitFun SSH state".to_string(), - ); - } - if state.get_ssh_manager_async().await.is_err() - || state.get_remote_file_service_async().await.is_err() - { - return Some("Remote workspace search services are unavailable".to_string()); - } - return None; + return Some("Flashgrep is not supported for remote workspaces".to_string()); } if !workspace_search_feature_enabled().await { diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index c3d2ff21c5..dea2bd840d 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -567,13 +567,7 @@ pub fn create_main_window( ); let main_url = if use_development_frontend() { - match "http://localhost:1422".parse() { - Ok(url) => WebviewUrl::External(url), - Err(e) => { - error!("Invalid dev URL, fallback to app URL: {}", e); - WebviewUrl::App("index.html".into()) - } - } + app_url(app_handle, "") } else { frontend_workbench.active_frontend_url() }; @@ -755,10 +749,24 @@ fn show_main_window_for_startup( } } -fn app_url(path: &str) -> WebviewUrl { +fn development_frontend_url( + dev_url: Option<&tauri::Url>, + path: &str, +) -> Result { + let base = dev_url.ok_or_else(|| "Tauri build.devUrl is not configured".to_string())?; + if path.is_empty() { + return Ok(base.clone()); + } + base.join(path).map_err(|error| error.to_string()) +} + +fn app_url(app: &tauri::AppHandle, path: &str) -> WebviewUrl { if use_development_frontend() { - match format!("http://localhost:1422/{}", path).parse() { - Ok(url) => WebviewUrl::External(url), + match development_frontend_url(app.config().build.dev_url.as_ref(), path) { + Ok(url) => { + debug!("Development frontend URL resolved: {}", url); + WebviewUrl::External(url) + } Err(e) => { error!("Invalid dev URL, fallback to app URL: {}", e); WebviewUrl::App(path.into()) @@ -937,7 +945,7 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( return Ok(()); } - let url = app_url("?openbitfunWindow=agent-companion"); + let url = app_url(&app, "?openbitfunWindow=agent-companion"); let mut builder = tauri::WebviewWindowBuilder::new(&app, AGENT_COMPANION_WINDOW_LABEL, url) .title("OpenBitFun Agent Companion") .inner_size( @@ -1085,3 +1093,33 @@ pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { ); Ok(()) } + +#[cfg(test)] +mod development_frontend_tests { + use super::development_frontend_url; + + #[test] + fn windows_use_the_configured_development_origin() { + for origin in [ + "http://localhost:1422/", + "http://localhost:1432/", + "http://127.0.0.1:15432/app/", + ] { + let base = origin.parse().unwrap(); + assert_eq!(development_frontend_url(Some(&base), "").unwrap(), base); + assert_eq!( + development_frontend_url(Some(&base), "?openbitfunWindow=agent-companion") + .unwrap() + .as_str(), + format!("{origin}?openbitfunWindow=agent-companion") + ); + } + } + + #[test] + fn missing_development_url_does_not_choose_another_instance() { + assert!(development_frontend_url(None, "") + .unwrap_err() + .contains("build.devUrl")); + } +} diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index fe0d6941e8..51e7efbb6a 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:4cdcd7ff4dcd85a9", + "digest": "fnv1a64:255dceea3d2c1591", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -6472,7 +6472,7 @@ { "id": "search_build_index", "surface": "tauri_command", - "remoteWorkspace": "RemoteRouted", + "remoteWorkspace": "RemoteUnsupported", "peer": { "kind": "proxied" }, @@ -6520,7 +6520,7 @@ { "id": "search_get_repo_status", "surface": "tauri_command", - "remoteWorkspace": "RemoteRouted", + "remoteWorkspace": "RemoteUnsupported", "peer": { "kind": "proxied" }, @@ -6532,7 +6532,7 @@ { "id": "search_rebuild_index", "surface": "tauri_command", - "remoteWorkspace": "RemoteRouted", + "remoteWorkspace": "RemoteUnsupported", "peer": { "kind": "proxied" }, diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 6fcc483ed5..6725f75a70 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -626,12 +626,12 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("save_session_turn", Unaudited, Proxied, HANDLED), op("save_web_search_credential", Agnostic, Proxied, HANDLED), op("scan_workspace_info", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), - op("search_build_index", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), + op("search_build_index", Unsupported, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("search_file_contents", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("search_filenames", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("search_files", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("search_get_repo_status", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("search_rebuild_index", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), + op("search_get_repo_status", Unsupported, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), + op("search_rebuild_index", Unsupported, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("search_referenceable_sessions", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("search_session_content", Routed, Proxied, HANDLED), op("search_skill_market", Unaudited, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 9f462d43ac..3019128570 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -142,3 +142,9 @@ advertise a small handle limit and are included in the existing CI `OPENBITFUN_TEST_SFTP_SERVER` to an installed `sftp-server` executable and run the same command with the filter ending in `workspace_sftp::openssh_real_files_over_loopback_ssh -- --ignored`. + +For the remote Flashgrep distribution gate and retained protocol helpers, use: + +```bash +cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-ssh,workspace-search --lib remote_ssh::workspace_search::service::tests:: +``` diff --git a/src/crates/services/services-integrations/src/remote_ssh/workspace_search/service.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_search/service.rs index 97a1ab6532..427ddd071b 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/workspace_search/service.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_search/service.rs @@ -38,6 +38,11 @@ use std::time::Duration; use tokio::sync::{mpsc, Mutex, RwLock}; use tokio::time::{sleep, timeout}; +// Only host-native binaries are distributed. Keep remote operations explicitly gated. +fn require_remote_flashgrep() -> Result<(), String> { + Err("Flashgrep is not supported for remote workspaces".to_string()) +} + const REMOTE_STDIO_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); const REMOTE_STDIO_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); const REMOTE_STDIO_SESSION_IDLE_GRACE: Duration = Duration::from_secs(45); @@ -553,6 +558,7 @@ impl RemoteWorkspaceSearchService { } pub async fn get_index_status(&self, root_path: &str) -> Result { + require_remote_flashgrep()?; let session = self.get_or_open_stdio_session(root_path).await?; let repo_status: WorkspaceSearchRepoStatus = session.status().await?.into(); let active_task = match repo_status.active_task_id.clone() { @@ -579,6 +585,7 @@ impl RemoteWorkspaceSearchService { } pub async fn build_index(&self, root_path: &str) -> Result { + require_remote_flashgrep()?; let session = self.get_or_open_stdio_session(root_path).await?; let task = session.build_index().await?; let repo_status = session.status().await?; @@ -589,6 +596,7 @@ impl RemoteWorkspaceSearchService { } pub async fn rebuild_index(&self, root_path: &str) -> Result { + require_remote_flashgrep()?; let session = self.get_or_open_stdio_session(root_path).await?; let task = session.rebuild_index().await?; let repo_status = session.status().await?; @@ -602,6 +610,7 @@ impl RemoteWorkspaceSearchService { &self, request: ContentSearchRequest, ) -> Result { + require_remote_flashgrep()?; let repo_root = normalize_remote_workspace_path(&request.repo_root.to_string_lossy()); let session = self.get_or_open_stdio_session(&repo_root).await?; let scope = build_remote_scope( @@ -892,6 +901,7 @@ impl RemoteWorkspaceSearchService { } pub async fn glob(&self, request: GlobSearchRequest) -> Result { + require_remote_flashgrep()?; let repo_root = normalize_remote_workspace_path(&request.repo_root.to_string_lossy()); let session = self.get_or_open_stdio_session(&repo_root).await?; let search_path = request @@ -946,6 +956,13 @@ impl RemoteWorkspaceSearchService { root_path: &str, ) -> Result { let context = self.ensure_remote_search_context(root_path).await?; + self.open_stdio_session(context).await + } + + async fn open_stdio_session( + &self, + context: RemoteSearchContext, + ) -> Result { let key = remote_stdio_session_key(&context.connection.connection_id, &context.repo_root); if let Some(entry) = REMOTE_STDIO_SESSIONS.read().await.get(&key).cloned() { @@ -1528,12 +1545,35 @@ mod tests { #[tokio::test] async fn remote_search_rejects_non_linux_before_stdio_open() { + let provider = Arc::new(FakeRemoteSearchProvider { + cached_os_type: Some("Darwin".into()), + connection_id: "os-gate".into(), + remote_root: "/Users/example/project".into(), + fail_stdio_spawn: false, + resolve_count: AtomicU64::new(0), + stdio_spawn_count: AtomicU64::new(0), + }); + let service = RemoteWorkspaceSearchService::new(provider.clone()); + let error = match service + .get_or_open_stdio_session("/Users/example/project") + .await + { + Ok(_) => panic!("non-Linux remotes must not open Flashgrep"), + Err(error) => error, + }; + assert!(error.contains("supports Linux only")); + assert!(error.contains("Darwin")); + assert_eq!(provider.stdio_spawn_count.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn remote_flashgrep_rejects_requests_before_provider_io() { let _test_guard = REMOTE_SEARCH_TEST_LOCK.lock().await; clear_remote_search_test_state().await; let provider = Arc::new(FakeRemoteSearchProvider { - cached_os_type: Some("Darwin".to_string()), + cached_os_type: Some("Linux".to_string()), connection_id: "conn-1".to_string(), - remote_root: "/Users/example/project".to_string(), + remote_root: "/home/example/project".to_string(), fail_stdio_spawn: false, resolve_count: AtomicU64::new(0), stdio_spawn_count: AtomicU64::new(0), @@ -1541,12 +1581,52 @@ mod tests { let service = RemoteWorkspaceSearchService::new(provider.clone()); let error = service - .get_index_status("/Users/example/project") + .get_index_status("/home/example/project") .await - .expect_err("non-linux remotes must fail before opening flashgrep"); + .expect_err("remote Flashgrep must be gated before provider IO"); - assert!(error.contains("supports Linux only")); - assert!(error.contains("Darwin")); + assert!(error.contains("Flashgrep is not supported for remote workspaces")); + assert_eq!( + service.build_index("/remote/repo").await.unwrap_err(), + error + ); + assert_eq!( + service.rebuild_index("/remote/repo").await.unwrap_err(), + error + ); + assert_eq!( + service + .glob(GlobSearchRequest { + repo_root: "/remote/repo".into(), + search_path: None, + pattern: "**/*".into(), + limit: 10, + }) + .await + .unwrap_err(), + error + ); + assert_eq!( + service + .search_content(ContentSearchRequest { + repo_root: "/remote/repo".into(), + search_path: None, + pattern: "needle".into(), + output_mode: crate::workspace_search::ContentSearchOutputMode::Content, + case_sensitive: false, + use_regex: false, + whole_word: false, + multiline: false, + max_results: None, + globs: vec![], + file_types: vec![], + exclude_file_types: vec![], + }) + .await + .unwrap_err(), + error + ); + assert_eq!(provider.resolve_count.load(Ordering::Relaxed), 0); assert_eq!(provider.stdio_spawn_count.load(Ordering::Relaxed), 0); } @@ -1582,10 +1662,12 @@ mod tests { }); let service = RemoteWorkspaceSearchService::new(provider.clone()); - let error = service - .get_index_status(repo_root) - .await - .expect_err("resolved non-Linux connection should reject without using stale cache"); + let error = match service.get_or_open_stdio_session(repo_root).await { + Ok(_) => { + panic!("resolved non-Linux connection should reject without using stale cache") + } + Err(error) => error, + }; assert_eq!(provider.resolve_count.load(Ordering::Relaxed), 1); assert!(error.contains("Darwin")); @@ -1607,10 +1689,25 @@ mod tests { }); let service = RemoteWorkspaceSearchService::new(provider.clone()); - let error = service - .get_index_status(repo_root) + let error = match service + .open_stdio_session(RemoteSearchContext { + connection: RemoteWorkspaceEntry { + connection_id: "conn-guard".into(), + connection_name: "test".into(), + ssh_host: "example.test".into(), + remote_root: repo_root.into(), + }, + binary_path: "/remote/flashgrep".into(), + repo_root: repo_root.into(), + storage_root: "/remote/search".into(), + remote_arch: "x86_64".into(), + local_binary_sha256: "fixture".into(), + }) .await - .expect_err("fake provider rejects stdio spawn"); + { + Ok(_) => panic!("fake provider rejects stdio spawn"), + Err(error) => error, + }; assert!(error.contains("spawn failed")); assert_eq!(provider.stdio_spawn_count.load(Ordering::Relaxed), 1); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index e6206039b7..d437b3ee14 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -175,13 +175,12 @@ const WorkspaceItem: React.FC = ({ WORKSPACE_SEARCH_AVAILABLE && isActive && workspaceSearchEnabled - && ( - workspace.workspaceKind === WorkspaceKind.Normal - || workspace.workspaceKind === WorkspaceKind.Remote - ); + && !workspaceIsRemote + && workspace.workspaceKind === WorkspaceKind.Normal; const workspaceSearchIndex = useWorkspaceSearchIndex({ workspacePath: canShowSearchIndex ? workspace.rootPath : undefined, enabled: canShowSearchIndex, + isRemote: workspaceIsRemote, }); useEffect(() => { diff --git a/src/web-ui/src/app/global-search/interactiveCapabilityCatalog.ts b/src/web-ui/src/app/global-search/interactiveCapabilityCatalog.ts index 7e932d300a..0b6058303c 100644 --- a/src/web-ui/src/app/global-search/interactiveCapabilityCatalog.ts +++ b/src/web-ui/src/app/global-search/interactiveCapabilityCatalog.ts @@ -141,10 +141,13 @@ export interface InteractiveCapabilityCatalog { definitions: InteractiveCapabilityControlDefinition[]; } -// Keep the shared host contract intact while hiding suspended frontend controls. +// Keep the host contract intact while filtering controls unavailable for the workspace. const catalog = generatedCatalog as InteractiveCapabilityCatalog; const suspendedSearchItems = new Set(['accelerated-search', 'search-index']); -export const INTERACTIVE_CAPABILITY_CATALOG: InteractiveCapabilityCatalog = WORKSPACE_SEARCH_AVAILABLE +export function getInteractiveCapabilityCatalog( + workspaceSearchAvailable = WORKSPACE_SEARCH_AVAILABLE, +): InteractiveCapabilityCatalog { + return workspaceSearchAvailable ? catalog : { ...catalog, @@ -178,6 +181,10 @@ export const INTERACTIVE_CAPABILITY_CATALOG: InteractiveCapabilityCatalog = WORK }), }; +} + +export const INTERACTIVE_CAPABILITY_CATALOG = getInteractiveCapabilityCatalog(); + const capabilityById = new Map( INTERACTIVE_CAPABILITY_CATALOG.capabilities.map((capability) => [capability.id, capability]), ); diff --git a/src/web-ui/src/app/global-search/providers/interactiveCapabilitySearchProvider.test.ts b/src/web-ui/src/app/global-search/providers/interactiveCapabilitySearchProvider.test.ts index 46cb37026d..c8517b9b62 100644 --- a/src/web-ui/src/app/global-search/providers/interactiveCapabilitySearchProvider.test.ts +++ b/src/web-ui/src/app/global-search/providers/interactiveCapabilitySearchProvider.test.ts @@ -17,22 +17,18 @@ function request(query: string): GlobalSearchRequest { } describe('interactiveCapabilitySearchProvider', () => { - it('hides suspended Flashgrep controls while keeping session-title controls', async () => { + it('restores accelerated search in the settings catalog', () => { const capability = INTERACTIVE_CAPABILITY_CATALOG.capabilities.find( ({ id }) => id === 'setting.workspace.session', ); - expect(capability?.options.map(({ id }) => id)).toEqual(['session-title-generation']); - expect(capability?.items.some(({ id }) => id === 'accelerated-search' || id === 'search-index')) - .toBe(false); - for (const query of ['Flashgrep', 'accelerated workspace search']) { - const result = await interactiveCapabilitySearchProvider.search( - request(query), new AbortController().signal, - ); - if (query === 'Flashgrep') expect(result.items).toEqual([]); - expect(result.items.some(({ target }) => target.kind === 'capability' - && (target.itemId === 'accelerated-search' || target.itemId === 'search-index'))) - .toBe(false); - } + expect(capability?.options.some(({ id }) => id === 'workspace-search')).toBe(true); + expect(capability?.items.some(({ id }) => id === 'accelerated-search')).toBe(true); + }); + it('hides Flashgrep search entries for remote workspaces', async () => { + const query = request('Flashgrep'); + query.currentWorkspace = { workspaceKind: 'remote' } as typeof query.currentWorkspace; + const result = await interactiveCapabilitySearchProvider.search(query, new AbortController().signal); + expect(result.items).toEqual([]); }); it('uses the curated feature-and-settings contract', () => { expect(INTERACTIVE_CAPABILITY_CATALOG.capabilities).toHaveLength( diff --git a/src/web-ui/src/app/global-search/providers/interactiveCapabilitySearchProvider.ts b/src/web-ui/src/app/global-search/providers/interactiveCapabilitySearchProvider.ts index 4f60a76858..0b2809201e 100644 --- a/src/web-ui/src/app/global-search/providers/interactiveCapabilitySearchProvider.ts +++ b/src/web-ui/src/app/global-search/providers/interactiveCapabilitySearchProvider.ts @@ -1,5 +1,6 @@ import { INTERACTIVE_CAPABILITY_CATALOG, + getInteractiveCapabilityCatalog, type InteractiveCapability, } from '../interactiveCapabilityCatalog'; import { scoreTextMatch } from '../searchMatching'; @@ -46,7 +47,10 @@ export const interactiveCapabilitySearchProvider: GlobalSearchProvider = { search: (request) => { if (request.scope === 'content' || !request.query) return { items: [] }; - const items = INTERACTIVE_CAPABILITY_CATALOG.capabilities + const catalog = request.currentWorkspace?.workspaceKind === 'remote' + ? getInteractiveCapabilityCatalog(false) + : INTERACTIVE_CAPABILITY_CATALOG; + const items = catalog.capabilities .map((capability) => { const item = matchingItem(capability, request.query); const textScore = scoreTextMatch(request.query, [ diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 4b94ff2c3b..8a7098b483 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:4cdcd7ff4dcd85a9" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:255dceea3d2c1591" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller diff --git a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.test.tsx b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.test.tsx index a093106505..51bf70e13b 100644 --- a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.test.tsx @@ -2,15 +2,18 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { BrowserDesktopControlSettingsPage, ExecutionSettingsPage } from './RuntimeSettingsPages'; +import { BrowserDesktopControlSettingsPage, ExecutionSettingsPage, SessionWorkspaceSettingsPage } from './RuntimeSettingsPages'; const mocks = vi.hoisted(() => { Object.defineProperty(window, '__TAURI__', { configurable: true, value: {} }); return { invoke: vi.fn(), setConfig: vi.fn(), setEnabled: vi.fn(), getConfig: vi.fn(), error: vi.fn(), t: (key: string) => key, + workspace: null as { workspaceKind: string } | null, }; }); +vi.mock('@/infrastructure/api', () => ({ globalAPI: {}, workspaceAPI: {} })); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ workspace: mocks.workspace }) })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: mocks.t }) })); vi.mock('@/infrastructure/i18n', () => ({ i18nService: { formatNumber: String } })); vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { invoke: mocks.invoke } })); @@ -19,7 +22,7 @@ vi.mock('../services/ConfigManager', () => ({ configManager: { getConfig: mocks.getConfig, getOptionalConfig: async () => true, setConfig: mocks.setConfig, } })); vi.mock('../hooks/useComputerUseEnabled', () => ({ useComputerUseEnabled: () => ({ computerUseEnabled: false, setComputerUseEnabled: mocks.setEnabled }) })); -vi.mock('../services/AIExperienceConfigService', () => ({ aiExperienceConfigService: {} })); +vi.mock('../services/AIExperienceConfigService', () => ({ aiExperienceConfigService: { getSettingsAsync: async () => ({ enable_workspace_search: true }) } })); vi.mock('../services/AgentCompanionPetService', () => ({ DEFAULT_AGENT_COMPANION_PET: 'default' })); vi.mock('../services/PermissionConfigService', async (original) => ({ ...await original(), @@ -47,6 +50,7 @@ const button = (label: string) => Array.from(container.querySelectorAll('button' const render = async () => { await act(async () => root.render()); }; beforeEach(() => { + mocks.workspace = null; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; status = { ...readyStatus }; mocks.getConfig.mockReset().mockResolvedValue(null); @@ -162,3 +166,12 @@ describe('Browser and desktop control settings', () => { expect(container.querySelector('select')).toBeNull(); }); }); + + +describe('Flashgrep settings availability', () => { + it.each(['normal', 'remote'])('only exposes indexing for local workspaces (%s)', async (workspaceKind) => { + mocks.workspace = { workspaceKind }; + await act(async () => root.render()); + expect(container.textContent?.includes('features.workspaceSearch.enable')).toBe(workspaceKind === 'normal'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.tsx b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.tsx index 6c0b6d0c30..aae5355112 100644 --- a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.tsx +++ b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.tsx @@ -59,6 +59,8 @@ import type { } from '../types'; import { GlobalPermissionRulesDialog } from './GlobalPermissionRulesDialog'; import SessionTitleConfig from './SessionTitleConfig'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { isRemoteWorkspace } from '@/shared/types/global-state'; import { WORKSPACE_SEARCH_AVAILABLE } from '@/infrastructure/config/workspaceSearchAvailability'; import ReviewCapacitySection from './ReviewCapacitySection'; import ToolJsonRepairSection from './ToolJsonRepairSection'; @@ -945,6 +947,7 @@ const RuntimeSettingsPage: React.FC = ({ const appearanceView = page; const showsExecutionSettings = page === 'execution'; + const { workspace } = useCurrentWorkspace(); const requiresExperienceSettings = page === 'pet' || page === 'session-workspace'; if (loadError) { return ( @@ -1118,8 +1121,8 @@ const RuntimeSettingsPage: React.FC = ({ {page === 'session-workspace' && settings ? ( <> - {/* Flashgrep entry stays hidden while its binaries are not distributed. */} - {WORKSPACE_SEARCH_AVAILABLE && ( + {/* Accelerated search is available for local workspaces only. */} + {WORKSPACE_SEARCH_AVAILABLE && !isRemoteWorkspace(workspace) && ( ({ globalThis.IS_REACT_ACT_ENVIRONMENT = true; -describe('temporarily suspended workspace search', () => { +describe('workspace search availability', () => { + beforeEach(() => vi.clearAllMocks()); it.each(['/local/repo', '/remote/repo'])('ignores an existing enabled preference for %s', async (workspacePath) => { const host = document.createElement('div'); const root = createRoot(host); let result: ReturnType; function Probe() { - result = useWorkspaceSearchIndex({ workspacePath, enabled: true }); + result = useWorkspaceSearchIndex({ workspacePath, enabled: true, isRemote: true }); return null; } await act(async () => root.render(createElement(Probe))); @@ -39,4 +40,20 @@ describe('temporarily suspended workspace search', () => { await act(async () => root.unmount()); } }); + it('polls an enabled local workspace', async () => { + vi.mocked(workspaceAPI.getSearchRepoStatus).mockResolvedValue({ repoStatus: {} } as never); + const host = document.createElement('div'); + const root = createRoot(host); + function Probe() { + useWorkspaceSearchIndex({ workspacePath: '/local/repo', enabled: true }); + return null; + } + await act(async () => root.render(createElement(Probe))); + try { + expect(workspaceAPI.getSearchRepoStatus).toHaveBeenCalledWith('/local/repo'); + } finally { + await act(async () => root.unmount()); + } + }); + }); diff --git a/src/web-ui/src/tools/file-explorer/search/useWorkspaceSearchIndex.ts b/src/web-ui/src/tools/file-explorer/search/useWorkspaceSearchIndex.ts index 86c84e52c0..366b2e22a2 100644 --- a/src/web-ui/src/tools/file-explorer/search/useWorkspaceSearchIndex.ts +++ b/src/web-ui/src/tools/file-explorer/search/useWorkspaceSearchIndex.ts @@ -30,7 +30,8 @@ function workspaceSearchUnsupportedReason( return 'non_git'; } if ( - message.includes('Workspace search is disabled') + message.includes('Flashgrep is not supported for remote workspaces') + || message.includes('Workspace search is disabled') || message.includes('Workspace search daemon is unavailable') || message.includes('Remote workspace search status is not managed') ) { @@ -42,6 +43,7 @@ function workspaceSearchUnsupportedReason( export interface UseWorkspaceSearchIndexOptions { workspacePath?: string; enabled?: boolean; + isRemote?: boolean; } export interface UseWorkspaceSearchIndexResult { @@ -66,8 +68,8 @@ function isTaskActive(status: WorkspaceSearchIndexStatus | null): boolean { export function useWorkspaceSearchIndex( options: UseWorkspaceSearchIndexOptions = {} ): UseWorkspaceSearchIndexResult { - const { workspacePath, enabled: requestedEnabled = true } = options; - const enabled = WORKSPACE_SEARCH_AVAILABLE && requestedEnabled; + const { workspacePath, enabled: requestedEnabled = true, isRemote = false } = options; + const enabled = WORKSPACE_SEARCH_AVAILABLE && !isRemote && requestedEnabled; const [indexStatus, setIndexStatus] = useState(null); const [loading, setLoading] = useState(false); diff --git a/src/web-ui/vite.config.ts b/src/web-ui/vite.config.ts index ce484601ef..17e24ebf67 100644 --- a/src/web-ui/vite.config.ts +++ b/src/web-ui/vite.config.ts @@ -14,7 +14,10 @@ import { verifyHarmonyFontSources, } from "../../scripts/web-font-profile.mjs"; +import { resolveDevServerPorts } from '../../scripts/dev-server-ports.mjs'; + const host = process.env.TAURI_DEV_HOST; +const { port: devPort, hmrPort } = resolveDevServerPorts(); const designSystemUiSourceDirectory = path.resolve( __dirname, '../../design-system/packages/ui/src', @@ -192,8 +195,8 @@ export default defineConfig(({ mode, command }) => { clearScreen: false, // 2. tauri expects a fixed port, fail if that port is not available server: { - port: 1422, - // Tauri devUrl is fixed to http://localhost:1422. + port: devPort, + // The desktop launcher uses the same configured development port. // If Vite silently falls back to another port, the desktop webview stays blank. strictPort: true, host: host || "localhost", @@ -201,7 +204,7 @@ export default defineConfig(({ mode, command }) => { hmr: { protocol: "ws", host: host || "localhost", - port: 1421, + port: hmrPort, }, // Allow access to workspace root for dependencies like monaco-editor fs: {