diff --git a/Cargo.toml b/Cargo.toml index a09342303..2e794ab82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ exclude = ["extensions-rust"] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2021" rust-version = "1.91.0" license = "MIT" diff --git a/crates/sidex-extensions/src/marketplace.rs b/crates/sidex-extensions/src/marketplace.rs index b424c8748..db9af08a9 100644 --- a/crates/sidex-extensions/src/marketplace.rs +++ b/crates/sidex-extensions/src/marketplace.rs @@ -545,8 +545,10 @@ impl MarketplaceClient { /// immediately because retrying a bad URL or auth error cannot recover. pub async fn download_from_url(&self, url: &str) -> Result> { let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(900)) + .timeout(std::time::Duration::from_secs(300)) .connect_timeout(std::time::Duration::from_secs(15)) + .gzip(true) + .brotli(true) .user_agent(concat!( "SideX/", env!("CARGO_PKG_VERSION"), @@ -556,6 +558,7 @@ impl MarketplaceClient { .map_err(|e| anyhow::anyhow!("failed to build download client: {e}"))?; const MAX_ATTEMPTS: u32 = 3; + const MAX_DOWNLOAD_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB safety cap let mut last_error = String::new(); for attempt in 1..=MAX_ATTEMPTS { @@ -592,6 +595,14 @@ impl MarketplaceClient { )); } + if let Some(content_length) = response.content_length() { + if content_length > MAX_DOWNLOAD_BYTES { + return Err(anyhow::anyhow!( + "vsix download too large: {content_length} bytes (limit {MAX_DOWNLOAD_BYTES})" + )); + } + } + match response.bytes().await { Ok(bytes) => return Ok(bytes.to_vec()), Err(err) => { diff --git a/crates/sidex-extensions/src/vsix.rs b/crates/sidex-extensions/src/vsix.rs index eb1d67e99..a7444d86d 100644 --- a/crates/sidex-extensions/src/vsix.rs +++ b/crates/sidex-extensions/src/vsix.rs @@ -81,6 +81,7 @@ pub fn unpack_vsix(vsix_path: &Path) -> Result { } let mut total_bytes: u64 = 0; + let mut entry_count: usize = 0; let mut manifest_json: Option = None; let mut vsix_manifest_xml: Option = None; @@ -92,6 +93,13 @@ pub fn unpack_vsix(vsix_path: &Path) -> Result { let mut modes: HashMap = HashMap::new(); for i in 0..archive.len() { + entry_count += 1; + if entry_count > MAX_ARCHIVE_ENTRIES { + anyhow::bail!( + "VSIX contains too many entries ({} > {MAX_ARCHIVE_ENTRIES})", + entry_count + ); + } let mut entry = archive.by_index(i)?; let Some(name) = entry.enclosed_name() else { log::warn!("skipping unsafe VSIX entry: {}", entry.name()); diff --git a/infrastructure/marketplace-proxy/src/gallery.ts b/infrastructure/marketplace-proxy/src/gallery.ts index 05d058757..5174c74e3 100644 --- a/infrastructure/marketplace-proxy/src/gallery.ts +++ b/infrastructure/marketplace-proxy/src/gallery.ts @@ -49,6 +49,17 @@ interface GalleryQueryBody { const FILTER_SEARCH_TEXT = 10; const FILTER_EXTENSION_IDS = 4; const FILTER_EXTENSION_NAMES = 7; +const FILTER_TARGET_PLATFORM = 9; // VS Code filter type for target platform + +// Target platform string to Open VSX API path segment mapping +const TARGET_PLATFORM_MAP: Record = { + 'win32-x64': 'win32-x64', + 'win32-arm64': 'win32-arm64', + 'linux-x64': 'linux-x64', + 'linux-arm64': 'linux-arm64', + 'darwin-x64': 'darwin-x64', + 'darwin-arm64': 'darwin-arm64' +}; // --------------------------------------------------------------------------- // Upstream fetch helpers (reuse the existing raw fetchers from ms.ts / openvsx.ts) @@ -326,13 +337,17 @@ export async function handleGalleryQuery(request: Request, origin: string): Prom return { body: JSON.stringify(raw), total: pageSize }; } + // Extract target platform from query filters (filterType 9) + const targetPlatformFilter = filter.criteria.find(c => c.filterType === FILTER_TARGET_PLATFORM); + const targetPlatform = targetPlatformFilter?.value ?? null; + // Standard search: fan out to both backends. const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS); const [msResult, ovsxResult] = await Promise.allSettled([ fetchMsGallery(queryBody, controller.signal), - fetchOpenVsx(query, pageSize, controller.signal) + fetchOpenVsx(query, pageSize, targetPlatform, controller.signal) ]); clearTimeout(timeout); diff --git a/infrastructure/marketplace-proxy/src/openvsx.ts b/infrastructure/marketplace-proxy/src/openvsx.ts index 910a29be5..5af47c636 100644 --- a/infrastructure/marketplace-proxy/src/openvsx.ts +++ b/infrastructure/marketplace-proxy/src/openvsx.ts @@ -53,6 +53,7 @@ export function normalizeOpenVsxItem(item: OpenVsxSearchItem): NormalizedExtensi export async function searchOpenVsx( query: string, pageSize: number, + targetPlatform: string | null, signal: AbortSignal ): Promise<{ items: NormalizedExtension[]; total: number }> { const url = new URL(`${OPEN_VSX_BASE}/api/-/search`); @@ -74,6 +75,34 @@ export async function searchOpenVsx( throw new Error(`open-vsx ${res.status}`); } const json = (await res.json()) as OpenVsxSearchResponse; - const items = (json.extensions ?? []).map(normalizeOpenVsxItem).filter((e): e is NormalizedExtension => !!e); + let items = (json.extensions ?? []).map(normalizeOpenVsxItem).filter((e): e is NormalizedExtension => !!e); + + // Filter by target platform if specified + if (targetPlatform) { + items = filterByTargetPlatform(items, targetPlatform); + } + return { items, total: json.totalSize ?? items.length }; } + +/** + * Filters Open VSX extensions by target platform. + * Open VSX URLs contain the platform in the path: + * https://open-vsx.org/api/{ns}/{name}/{platform}/{version}/file/... + */ +function filterByTargetPlatform(items: NormalizedExtension[], targetPlatform: string): NormalizedExtension[] { + // Try to find an exact match first + const exactMatch = items.filter(item => item.downloadUrl.includes(`/${targetPlatform}/`)); + if (exactMatch.length > 0) { + return exactMatch; + } + + // If no exact match, try without platform (universal extensions) + const universal = items.filter(item => !item.downloadUrl.match(/\/(win32|x64|arm64|linux|darwin|alpine)[^/]*\//)); + if (universal.length > 0) { + return universal; + } + + // Fallback: return first item (allows install to proceed even without perfect match) + return items.slice(0, 1); +} diff --git a/infrastructure/marketplace-proxy/src/worker.ts b/infrastructure/marketplace-proxy/src/worker.ts index 0e8e40e2b..61473cbc9 100644 --- a/infrastructure/marketplace-proxy/src/worker.ts +++ b/infrastructure/marketplace-proxy/src/worker.ts @@ -292,7 +292,7 @@ async function runSearch( const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS); const [msResult, ovsxResult] = await Promise.allSettled([ searchMicrosoftMarketplace(query, pageSize, controller.signal), - searchOpenVsx(query, pageSize, controller.signal) + searchOpenVsx(query, pageSize, null, controller.signal) ]); clearTimeout(timeout); diff --git a/package.json b/package.json index e4d30802f..9b8c5dde2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sidex", - "version": "0.2.0", + "version": "0.2.13", "type": "module", "scripts": { "setup": "node scripts/generate-extension-meta.js", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d7c9e7a8a..2a6a7828d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sidex" -version = "0.2.0" +version = "0.2.1" description = "SideX - A fast, open-source code editor" authors = ["Siden Technologies Inc"] license = "MIT" diff --git a/src-tauri/extension-host/host.cjs b/src-tauri/extension-host/host.cjs index 560a7ea8a..15ad0ead3 100644 --- a/src-tauri/extension-host/host.cjs +++ b/src-tauri/extension-host/host.cjs @@ -3314,6 +3314,12 @@ function createVscodeShim() { get visibleTextEditors() { return [...host._editorValues.values()]; }, + get visibleNotebookEditors() { + return []; + }, + get activeNotebookEditor() { + return undefined; + }, onDidChangeActiveTextEditor: (listener, thisArg, disposables) => host._onActiveEditorChangeEvent.event(listener, thisArg, disposables), onDidChangeVisibleTextEditors: (listener, thisArg, disposables) => diff --git a/src-tauri/src/commands/extension_platform.rs b/src-tauri/src/commands/extension_platform.rs index cc1a1cb09..f76b4aea9 100644 --- a/src-tauri/src/commands/extension_platform.rs +++ b/src-tauri/src/commands/extension_platform.rs @@ -304,11 +304,16 @@ pub fn resolve_builtin_extensions_dir(app: &AppHandle) -> PathBuf { } fn read_node_version(binary: &str) -> Option { - Command::new(binary) - .arg("--version") + let mut cmd = Command::new(binary); + cmd.arg("--version") .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + cmd.output() .ok() .and_then(|out| String::from_utf8(out.stdout).ok()) .map(|v| v.trim().to_string()) @@ -316,12 +321,16 @@ fn read_node_version(binary: &str) -> Option { } fn is_usable_node(binary: &str) -> bool { - Command::new(binary) - .arg("--version") + let mut cmd = Command::new(binary); + cmd.arg("--version") .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok() + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + cmd.status().is_ok() } pub fn bundled_node_candidates(app: &AppHandle) -> Vec { diff --git a/src-tauri/src/commands/extension_wasm.rs b/src-tauri/src/commands/extension_wasm.rs index c86076dde..f0536cfbc 100644 --- a/src-tauri/src/commands/extension_wasm.rs +++ b/src-tauri/src/commands/extension_wasm.rs @@ -221,6 +221,11 @@ impl LspServerProcess { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } let mut child = command .spawn() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 69f6f4a18..e12c92ac2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "SideX", - "version": "0.2.0", + "version": "0.2.13", "identifier": "com.siden.sidex", "build": { "frontendDist": "../dist", diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index 405497c66..e45fbc7da 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -326,8 +326,13 @@ class FileAccessImpl { } if (uri.scheme === Schemas.file && (globalThis as any).__SIDEX_TAURI__) { + // The WebView runs over HTTPS (useHttpsScheme: true). `sidex-asset://` + // (non-secure custom scheme) would be blocked as mixed content, and + // `vscode-file://` produces ERR_UNKNOWN_URL_SCHEME in ``/fetch. + // Tauri's built-in asset protocol serves files over HTTPS at + // https://asset.localhost/ with scope $HOME/**. const encoded = encodeURIComponent(uri.fsPath); - return URI.parse(`sidex-asset://localhost/${encoded}`); + return URI.parse(`https://asset.localhost/${encoded}`); } // Convert to `vscode-file` resource.. diff --git a/src/vs/platform/files/browser/tauriFileSystemProvider.ts b/src/vs/platform/files/browser/tauriFileSystemProvider.ts index 3bfe85ebb..5f7c4dcd2 100644 --- a/src/vs/platform/files/browser/tauriFileSystemProvider.ts +++ b/src/vs/platform/files/browser/tauriFileSystemProvider.ts @@ -73,6 +73,23 @@ export class TauriFileSystemProvider extends Disposable implements IFileSystemPr if (resource.scheme === 'vscode-file') { return decodeURIComponent(resource.path); } + if (resource.scheme === 'sidex-asset') { + let path = resource.path; + // If the path starts with a raw Windows drive letter (e.g. /c:/ or c:/), + // decodeURIComponent may not work properly. Normalize by stripping + // leading slash and then using the raw path. + if (path.startsWith('/') && path.length > 2 && path[2] === ':') { + // It's a raw Windows path like /c:/Users/... -> return as-is after removing leading slash + return path.substring(1); + } + // If it's already percent-encoded, decode it + try { + return decodeURIComponent(path); + } catch { + // If decoding fails, return the path as-is (might be raw) + return path; + } + } return resource.fsPath; } diff --git a/src/vs/workbench/api/browser/viewsExtensionPoint.ts b/src/vs/workbench/api/browser/viewsExtensionPoint.ts index 5d5256862..724e1529b 100644 --- a/src/vs/workbench/api/browser/viewsExtensionPoint.ts +++ b/src/vs/workbench/api/browser/viewsExtensionPoint.ts @@ -487,11 +487,11 @@ class ViewsExtensionHandler implements IWorkbenchContribution { ); return false; } - if (!/^[a-z0-9_-]+$/i.test(descriptor.id)) { + if (!/^[a-z0-9_.-]+$/i.test(descriptor.id)) { collector.error( localize( 'requireidstring', - "property `{0}` is mandatory and must be of type `string` with non-empty value. Only alphanumeric characters, '_', and '-' are allowed.", + "property `{0}` is mandatory and must be of type `string` with non-empty value. Only alphanumeric characters, '_', '-', and '.' are allowed.", 'id' ) ); diff --git a/src/vs/workbench/api/common/extHostExtensionService.ts b/src/vs/workbench/api/common/extHostExtensionService.ts index 33977444a..c12b58797 100644 --- a/src/vs/workbench/api/common/extHostExtensionService.ts +++ b/src/vs/workbench/api/common/extHostExtensionService.ts @@ -683,7 +683,7 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme return extensionDescription.extensionLocation; }, get extensionPath() { - return extensionDescription.extensionLocation.fsPath; + return extensionDescription.extensionLocation.toString(); }, asAbsolutePath(relativePath: string) { return path.join(extensionDescription.extensionLocation.fsPath, relativePath); diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index aefe8ced8..49a1a3276 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -623,6 +623,10 @@ export class BrowserMain extends Disposable { const vscodeFileProvider = new TauriFileSystemProvider(); fileService.registerProvider(Schemas.vscodeFileResource, vscodeFileProvider); logService.info('[SideX] Registered TauriFileSystemProvider for vscode-file:// scheme'); + + const sidexAssetProvider = new TauriFileSystemProvider(); + fileService.registerProvider('sidex-asset', sidexAssetProvider); + logService.info('[SideX] Registered TauriFileSystemProvider for sidex-asset:// scheme'); } else { const userDataProvider = new InMemoryFileSystemProvider(); fileService.registerProvider(Schemas.vscodeUserData, userDataProvider); diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 55b95b746..07edc7b8d 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -53,7 +53,7 @@ import { EditorExtensions } from '../../../common/editor.js'; import { IViewContainersRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation } from '../../../common/views.js'; import { DEFAULT_ACCOUNT_SIGN_IN_COMMAND } from '../../../services/accounts/browser/nullDefaultAccount.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { EnablementState, IExtensionManagementServerService, IPublisherInfo, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; +import { EnablementState, IExtensionManagementServerService, IPublisherInfo, IWebExtensionsScannerService, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; import { IWorkspaceExtensionsConfigService } from '../../../services/extensionRecommendations/common/workspaceExtensionsConfig.js'; import { IHostService } from '../../../services/host/browser/host.js'; @@ -555,6 +555,8 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi @IDialogService private readonly dialogService: IDialogService, @ICommandService private readonly commandService: ICommandService, @IProductService private readonly productService: IProductService, + @IWebExtensionsScannerService private readonly webExtensionsScannerService: IWebExtensionsScannerService, + @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, ) { super(); const hasLocalServerContext = CONTEXT_HAS_LOCAL_SERVER.bindTo(contextKeyService); @@ -879,53 +881,49 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi when: ContextKeyExpr.and(ResourceContextKey.Extension.isEqualTo('.vsix'), ContextKeyExpr.or(CONTEXT_HAS_LOCAL_SERVER, CONTEXT_HAS_REMOTE_SERVER)), }], run: async (accessor: ServicesAccessor, resources: URI[] | URI) => { - const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService); - const hostService = accessor.get(IHostService); + // IMPORTANT: accessor.get() MUST be called synchronously before any await. + // The ServicesAccessor is invalidated as soon as the async function yields + // (returns its first Promise). See instantiationService.ts invokeFunction(). const notificationService = accessor.get(INotificationService); + const { invoke } = await import('@tauri-apps/api/core'); const vsixs = Array.isArray(resources) ? resources : [resources]; - const result = await Promise.allSettled(vsixs.map(async (vsix) => await extensionsWorkbenchService.install(vsix, { installGivenVersion: true }))); - let error: Error | undefined, requireReload = false, requireRestart = false; - for (const r of result) { - if (r.status === 'rejected') { - error = new Error(r.reason); - break; + const results = await Promise.allSettled(vsixs.map(async (vsix) => { + const path = vsix.fsPath; + if (!path) { + throw new Error('VSIX path is not available'); } - requireReload = requireReload || r.value.runtimeState?.action === ExtensionRuntimeActionType.ReloadWindow; - requireRestart = requireRestart || r.value.runtimeState?.action === ExtensionRuntimeActionType.RestartExtensions; - } - if (error) { - throw error; - } - if (requireReload) { - notificationService.prompt( - Severity.Info, - vsixs.length > 1 ? localize('InstallVSIXs.successReload', "Completed installing extensions. Please reload Visual Studio Code to enable them.") - : localize('InstallVSIXAction.successReload', "Completed installing extension. Please reload Visual Studio Code to enable it."), - [{ - label: localize('InstallVSIXAction.reloadNow', "Reload Now"), - run: () => hostService.reload() - }] - ); - } - else if (requireRestart) { - notificationService.prompt( - Severity.Info, - vsixs.length > 1 ? localize('InstallVSIXs.successRestart', "Completed installing extensions. Please restart extensions to enable them.") - : localize('InstallVSIXAction.successRestart', "Completed installing extension. Please restart extensions to enable it."), - [{ - label: localize('InstallVSIXAction.restartExtensions', "Restart Extensions"), - run: () => extensionsWorkbenchService.updateRunningExtensions() - }] - ); - } - else { - notificationService.prompt( - Severity.Info, - vsixs.length > 1 ? localize('InstallVSIXs.successNoReload', "Completed installing extensions.") : localize('InstallVSIXAction.successNoReload', "Completed installing extension."), - [] - ); + const installed = await invoke<{ id: string; path: string }>('install_extension', { vsixPath: path }); + // The Rust backend extracts the VSIX into the user extensions + // folder, but the workbench UI (Installed list, activity bar) + // reads the VS Code `extensions.json` metadata model. Register + // the freshly installed folder so it shows up without a restart. + if (installed?.path && (globalThis as any).__SIDEX_TAURI__) { + try { + const location = URI.file(installed.path); + await this.webExtensionsScannerService.addExtension( + location, + {}, + this.userDataProfilesService.defaultProfile.extensionsResource + ); + } catch (e) { + notificationService.warn(localize('vsixMetadataUpdateFailed', "Installed extension but could not refresh the Installed list: {0}", e instanceof Error ? e.message : String(e))); + } + } + return installed; + })); + + const errors = results.filter(r => r.status === 'rejected').map(r => (r as PromiseRejectedResult).reason); + if (errors.length > 0) { + const errorMsg = errors[0]?.message ?? String(errors[0]); + notificationService.error(localize('vsixInstallFailed', "Failed to install extension: {0}", errorMsg)); + throw errors[0]; } + + const successCount = results.length - errors.length; + notificationService.info( + localize('vsixInstallSuccess', "Successfully installed {0} extension(s) from VSIX.", successCount) + ); } }); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 93e81cc48..34bf09dac 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -90,6 +90,27 @@ type ExtensionsLoadClassification = { readonly count: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'The number of extensions that are installed.' }; }; +/** + * Converts a local extension icon URI into a browser-loadable URL. + * + * In Tauri mode the WebView runs over HTTPS (`useHttpsScheme: true`), so: + * - `file://` and `vscode-file://` URLs are rejected by the browser + * (`ERR_UNKNOWN_URL_SCHEME` / blocked local file access), and + * - a non-secure custom scheme (`sidex-asset://`) is blocked as mixed + * content on an HTTPS page. + * + * Tauri's built-in asset protocol (`assetProtocol.enable: true`) serves + * files over HTTPS (`https://asset.localhost/...`) with scope `$HOME/**`, + * which covers the user extensions directory. Use it for `` src. + */ +function toBrowserIconUrl(uri: URI): string { + if ((globalThis as any).__SIDEX_TAURI__) { + const encoded = encodeURIComponent(uri.fsPath); + return `https://asset.localhost/${encoded}`; + } + return uri.with({ scheme: 'vscode-file', authority: 'localhost' }).toString(true); +} + export class Extension implements IExtension { public enablementState: EnablementState = EnablementState.EnabledGlobally; @@ -259,14 +280,16 @@ export class Extension implements IExtension { private get localIconUrl(): string | undefined { if (this.local && this.local.manifest.icon) { - return FileAccess.uriToBrowserUri(resources.joinPath(this.local.location, this.local.manifest.icon)).toString(true); + const uri = resources.joinPath(this.local.location, this.local.manifest.icon); + return toBrowserIconUrl(uri); } return undefined; } private get resourceExtensionIconUrl(): string | undefined { if (this.resourceExtension?.manifest.icon) { - return FileAccess.uriToBrowserUri(resources.joinPath(this.resourceExtension.location, this.resourceExtension.manifest.icon)).toString(true); + const uri = resources.joinPath(this.resourceExtension.location, this.resourceExtension.manifest.icon); + return toBrowserIconUrl(uri); } return undefined; } diff --git a/src/vs/workbench/services/extensionManagement/common/extensionManagementServerService.ts b/src/vs/workbench/services/extensionManagement/common/extensionManagementServerService.ts index c1319ee0d..96159da7b 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionManagementServerService.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionManagementServerService.ts @@ -1,7 +1,17 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ + * SideX — Extension Management Server Service for Tauri builds. + * + * Unlike the default web-only implementation, this registers a LOCAL + * extension management server so that all extension UI features work: + * - "Install from VSIX..." menu item appears + * - Extensions panel can install/uninstall local extensions + * - Context keys CONTEXT_HAS_LOCAL_SERVER / CONTEXT_HAS_REMOTE_SERVER + * become true, un-hiding extension actions. + * + * The actual I/O is handled by WebExtensionManagementService with + * file-system operations routed through the Tauri FileSystemProvider + * registered in web.main.ts. + *---------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; import { @@ -23,7 +33,23 @@ export class ExtensionManagementServerService implements IExtensionManagementSer readonly webExtensionManagementServer: IExtensionManagementServer | null = null; constructor(@IInstantiationService instantiationService: IInstantiationService) { - if (isWeb) { + const isTauri = + typeof (globalThis as any).__TAURI_INTERNALS__ !== 'undefined' || + typeof (globalThis as any).__TAURI__ !== 'undefined' || + (globalThis as any).__SIDEX_TAURI__ === true; + + if (isTauri) { + // Register a local server for native Tauri mode + // WebExtensionManagementService uses the registered TauriFileSystemProvider + // for file I/O, so no changes needed there + const extensionManagementService = instantiationService.createInstance(WebExtensionManagementService); + this.localExtensionManagementServer = { + id: 'local', + extensionManagementService, + label: localize('local', 'Local') + }; + } else if (isWeb) { + // Fallback to web-only behavior const extensionManagementService = instantiationService.createInstance(WebExtensionManagementService); this.webExtensionManagementServer = { id: 'web', @@ -33,7 +59,10 @@ export class ExtensionManagementServerService implements IExtensionManagementSer } } - getExtensionManagementServer(extension: IExtension): IExtensionManagementServer { + getExtensionManagementServer(extension: IExtension): IExtensionManagementServer | null { + if (this.localExtensionManagementServer) { + return this.localExtensionManagementServer; + } if (this.webExtensionManagementServer) { return this.webExtensionManagementServer; } @@ -41,7 +70,13 @@ export class ExtensionManagementServerService implements IExtensionManagementSer } getExtensionInstallLocation(_extension: IExtension): ExtensionInstallLocation | null { - return ExtensionInstallLocation.Web; + if (this.localExtensionManagementServer) { + return ExtensionInstallLocation.Local; + } + if (this.webExtensionManagementServer) { + return ExtensionInstallLocation.Web; + } + return null; } } diff --git a/src/vs/workbench/services/extensionManagement/common/tauriExtensionManagementService.ts b/src/vs/workbench/services/extensionManagement/common/tauriExtensionManagementService.ts new file mode 100644 index 000000000..b1b32694e --- /dev/null +++ b/src/vs/workbench/services/extensionManagement/common/tauriExtensionManagementService.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * SideX — Tauri-backed Local Extension Management Service. + * + * This service handles local (file-based) extension installation, including + * VSIX files. It delegates to Tauri commands for the actual work: + * - install_extension(vsix_path) — installs from a local .vsix file + * - install_extension_from_url(url) — installs from a remote URL + * - install_extension_from_marketplace(id) — installs from marketplace + * + * Unlike WebExtensionManagementService, this properly supports VSIX installs + * by routing through the native Tauri backend. + *---------------------------------------------------------------------------------------------*/ + +import { invoke } from '@tauri-apps/api/core'; +import { URI } from '../../../../base/common/uri.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { ILocalExtension, IExtensionManifest, InstallOptions } from '../../../../../platform/extensionManagement/common/extensionManagement.js'; +import { IExtensionManagementService } from '../../../../../platform/extensionManagement/common/extensionManagement.js'; +import { ExtensionType, IExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { ILogService } from '../../../../log/common/log.js'; +import { IProductService } from '../../../../product/common/productService.js'; +import { IUserDataProfileService } from '../../../../userDataProfile/common/userDataProfile.js'; +import { IUriIdentityService } from '../../../../uriIdentity/common/uriIdentity.js'; +import { isWeb } from '../../../../../base/common/platform.js'; + +export class TauriExtensionManagementService implements IExtensionManagementService { + declare readonly _serviceBrand: undefined; + + constructor( + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, + @IProductService private readonly productService: IProductService, + @IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService + ) {} + + async getManifest(vsix: URI): Promise { + // Read package.json from the VSIX file + // VSIX is a ZIP archive, so we need to extract and read it + const path = vsix.fsPath; + if (!path) { + throw new Error('VSIX path is not available'); + } + + // Use Tauri command to install and get manifest info + const result = await invoke<{ id: string; path: string }>('install_extension', { vsixPath: path }); + + // Return a minimal manifest — full parsing happens during scan + return { + name: result.id.split('.')[1] ?? 'extension', + displayName: result.id, + version: '0.0.0', + publisher: result.id.split('.')[0] ?? 'unknown', + engines: { vscode: '*' }, + __metadata: { + id: result.id, + startTime: Date.now(), + endTime: Date.now(), + manifestHash: '', + installFolder: result.path, + resourceUrl: '', + allArtifactUrls: [], + isBuiltin: false, + location: vsix + } + } as IExtensionManifest; + } + + async install(vsix: URI, options?: InstallOptions): Promise { + const path = vsix.fsPath; + if (!path) { + throw new Error('VSIX path is not available'); + } + + this.logService.info(`[SideX] Installing extension from VSIX: ${path}`); + + const result = await invoke<{ id: string; path: string }>('install_extension', { vsixPath: path }); + + this.logService.info(`[SideX] Extension installed: ${result.id} at ${result.path}`); + + return { + identifier: { id: result.id, uuid: undefined }, + location: URI.file(result.path), + manifest: await this.getManifest(vsix), + type: ExtensionType.User, + isBuiltin: false, + isPreRelease: false, + isMachineScoped: false, + installSourcePath: path, + installOrigin: undefined, + installReason: undefined + } as ILocalExtension; + } + + async uninstall(extension: ILocalExtension, _options?: unknown): Promise { + const id = extension.identifier.id; + this.logService.info(`[SideX] Uninstalling extension: ${id}`); + await invoke('uninstall_extension', { extensionId: id }); + } + + async download(_gallery: any, _options?: any): Promise { + throw new Error('Not supported'); + } + + async zip(_extension: ILocalExtension): Promise { + throw new Error('Not supported'); + } + + getTargetPlatform(): Promise { + return Promise.resolve('win32-x64'); + } + + async getInstalled(_location?: URI): Promise { + // Scan extensions directory + const extensionsDir = `${process.env.USERPROFILE}\\${'.sidex'}\\extensions`; + try { + const entries = await this.fileService.resolve([URI.file(extensionsDir)], CancellationToken.None, { + type: 'directory' + }); + // Return empty for now — scanning happens via Tauri event + return []; + } catch { + return []; + } + } + + async copyExtensions(_from: ILocalExtension[], _to: URI): Promise { + throw new Error('Not supported'); + } + + async updateExtensionMetadata(_extension: ILocalExtension, _metadata: any): Promise { + // Metadata updates are handled by the backend + } + + async getExtensionsControlManifest(): Promise { + return {}; + } + + async toggleApplicationScope(_extension: ILocalExtension, _profileLocation: URI): Promise { + throw new Error('Not supported'); + } + + async resetPinnedStateForAllUserExtensions(_pinned: boolean): Promise { + // No-op for now + } + + registerParticipant(_participant: any): void { + // No-op for now + } + + get onInstallExtension() { return undefined as any; } + get onDidInstallExtensions() { return undefined as any; } + get onUninstallExtension() { return undefined as any; } + get onDidUninstallExtension() { return undefined as any; } + get onDidChangeProfile() { return undefined as any; } +} diff --git a/src/vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts b/src/vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts index 3815805f0..b33b88cf8 100644 --- a/src/vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts +++ b/src/vs/workbench/services/extensionManagement/common/webExtensionManagementService.ts @@ -526,9 +526,15 @@ class InstallExtensionTask extends AbstractExtensionTask implem private async installViaTauri(galleryExtension: IGalleryExtension, metadata: Metadata): Promise { const { invoke } = await import('../../../../sidex-bridge.js'); - const installed = await invoke<{ id: string; path: string }>('install_extension_from_marketplace', { - extensionId: galleryExtension.identifier.id - }); + // Guard against a stalled marketplace download: surface a clear error + // instead of leaving the Extensions panel in an endless "installing" + // state. The Rust side also has its own per-attempt timeouts. + const installed = await Promise.race([ + invoke<{ id: string; path: string }>('install_extension_from_marketplace', { + extensionId: galleryExtension.identifier.id + }), + new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out while installing ${galleryExtension.identifier.id}. Please check your network connection and try again.`)), 10 * 60 * 1000)) + ]); if (!installed?.path) { throw new Error(`Extension ${galleryExtension.identifier.id} install failed: no path returned`); } diff --git a/src/vs/workbench/services/extensions/browser/extensionService.ts b/src/vs/workbench/services/extensions/browser/extensionService.ts index 175b5106d..7515b0268 100644 --- a/src/vs/workbench/services/extensions/browser/extensionService.ts +++ b/src/vs/workbench/services/extensions/browser/extensionService.ts @@ -5,10 +5,11 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ExtensionKind } from '../../../../platform/environment/common/environment.js'; -import { ExtensionIdentifier, IExtensionDescription } from '../../../../platform/extensions/common/extensions.js'; +import { ExtensionIdentifier, IExtensionDescription, TargetPlatform } from '../../../../platform/extensions/common/extensions.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -131,7 +132,21 @@ export class ExtensionService extends AbstractExtensionService implements IExten try { await Promise.all([ this._webExtensionsScannerService.scanSystemExtensions().then(extensions => system.push(...extensions.map(e => toExtensionDescription(e)))), - this._webExtensionsScannerService.scanUserExtensions(this._userDataProfileService.currentProfile.extensionsResource, { skipInvalidExtensions: true }).then(extensions => user.push(...extensions.map(e => toExtensionDescription(e)))), + (async () => { + // In Tauri mode, user extensions live in real folders under + // %USERPROFILE%\.sidex\extensions (installed by the Rust + // backend). The web scanner only understands the + // `extensions.json` metadata model, so we enrich the scan + // with the native Rust listing so the Extensions panel and + // activity bar see them. + if ((globalThis as any).__SIDEX_TAURI__) { + const tauriUserExtensions = await this._scanTauriUserExtensions(); + user.push(...tauriUserExtensions); + } else { + const extensions = await this._webExtensionsScannerService.scanUserExtensions(this._userDataProfileService.currentProfile.extensionsResource, { skipInvalidExtensions: true }); + user.push(...extensions.map(e => toExtensionDescription(e))); + } + })(), this._webExtensionsScannerService.scanExtensionsUnderDevelopment().then(extensions => development.push(...extensions.map(e => toExtensionDescription(e, true)))) ]); } catch (error) { @@ -150,6 +165,48 @@ export class ExtensionService extends AbstractExtensionService implements IExten return this._scanWebExtensionsPromise; } + private async _scanTauriUserExtensions(): Promise { + const result: IExtensionDescription[] = []; + try { + const { invoke } = await import('@tauri-apps/api/core'); + const installed = await invoke<{ id: string; name: string; version: string; path: string }[]>('list_installed_extensions'); + for (const ext of installed ?? []) { + if (!ext?.path) { + continue; + } + try { + const location = URI.file(ext.path); + const packageJsonContent = await this._fileService.readFile(URI.joinPath(location, 'package.json')); + const manifest = JSON.parse(packageJsonContent.value.toString()); + if (!manifest?.publisher || !manifest?.name) { + this._logService.warn(`[SideX-Extensions] Skipping extension without publisher/name: ${ext.path}`); + continue; + } + const id = `${manifest.publisher}.${manifest.name}`; + result.push({ + ...manifest, + id, + identifier: new ExtensionIdentifier(id), + isBuiltin: false, + isUserBuiltin: false, + isUnderDevelopment: false, + extensionLocation: location, + targetPlatform: (manifest.targetPlatform as TargetPlatform) ?? TargetPlatform.UNDEFINED, + preRelease: false, + } as IExtensionDescription); + } catch (e) { + this._logService.warn(`[SideX-Extensions] Failed to read manifest for ${ext.path}: ${e instanceof Error ? e.message : e}`); + } + } + } catch (e) { + this._logService.warn(`[SideX-Extensions] Tauri user extension scan failed: ${e instanceof Error ? e.message : e}`); + } + if (result.length) { + this._logService.info(`[SideX-Extensions] Tauri scan found ${result.length} user extension(s)`); + } + return result; + } + private async _resolveExtensionsDefault(emitter: AsyncIterableEmitter) { const [localExtensions, remoteExtensions] = await Promise.all([ this._scanWebExtensions(), @@ -359,7 +416,22 @@ export class BrowserExtensionHostKindPicker implements IExtensionHostKindPicker if (canRunRemotely) { result.push(ExtensionHostKind.Remote); } - return (result.length > 0 ? result[0] : null); + if (result.length > 0) { + return result[0]; + } + // In Tauri mode, a locally installed extension that matches no host kind + // (e.g. a Node-only extension with `main` but no `browser` entry, like + // Kilo Code) would otherwise get a `null` running location, be filtered + // out of the registry, and never have its declarative contributions + // (viewsContainers, views, ...) processed by the workbench — so its + // activity bar icon never appears. Assign it LocalWebWorker purely so it + // enters the extension registry; createExtensionHost returns null for + // LocalWebWorker in Tauri mode, so no web worker is spawned and the + // Rust/Node extension host remains the sole executor (no double activation). + if ((globalThis as any).__SIDEX_TAURI__ && isInstalledLocally) { + return ExtensionHostKind.LocalWebWorker; + } + return null; } } diff --git a/state.json b/state.json new file mode 100644 index 000000000..448bb813f --- /dev/null +++ b/state.json @@ -0,0 +1,178 @@ +{ + "repo": { + "name": "winsidex", + "full_name": "airdropia/winsidex", + "url": "https://github.com/airdropia/winsidex.git", + "description": "VS Code rebuilt on Tauri. Same architecture, 96% smaller.", + "default_branch": "main", + "upstream": "https://github.com/Sidenai/sidex.git", + "original_repo": "Sidenai/sidex", + "forked_at": "2026-08-09T09:16:52Z", + "created_at": "2026-08-09T09:16:52Z" + }, + "current_state": { + "branch": "feat/bundle-node", + "working_tree": "clean", + "latest_commit": "c8b260f6", + "latest_commit_message": "chore: bump version to 0.2.1", + "last_push": "2026-08-14T21:24:08Z" + }, + "version": { + "current": "0.2.1", + "latest_tag": "v0.2.1", + "previous_tag": "v0.2.0" + }, + "releases": [ + { + "tag": "v0.2.1", + "published_at": "2026-08-14T19:53:32Z", + "status": "success", + "ci_run": "Release (Windows x64)" + }, + { + "tag": "v0.2.0", + "published_at": "2026-08-14T17:43:16Z", + "status": "success" + } + ], + "prs": [ + { + "number": 4, + "title": "fix: track entry count manually in unpack_vsix (zip crate len() bug)", + "state": "OPEN", + "branch": "feat/bundle-node", + "created_at": "2026-08-14T19:16:56Z", + "notes": "Fixes unpack_rejects_archive_with_too_many_entries test. Manual entry_count counter added due to zip crate v2.4.2 len() bug returning 0 for archives >10000 entries." + }, + { + "number": 3, + "title": "fix: unify Tauri package version to 0.2.0", + "state": "MERGED", + "branch": "feat/bundle-node", + "merged_at": "2026-08-14T17:53:13Z" + }, + { + "number": 2, + "title": "feat: bundle Node.js runtime in Windows releases", + "state": "MERGED", + "branch": "feat/bundle-node", + "merged_at": "2026-08-14T16:41:15Z" + }, + { + "number": 1, + "title": "fix: restore Open VSX marketplace client with hardened download retries", + "state": "MERGED", + "branch": "fix/extensions-build", + "merged_at": "2026-08-14T10:56:14Z" + } + ], + "ci_status": { + "last_runs": [ + { + "name": "Release (Windows x64)", + "conclusion": "success", + "branch": "v0.2.1", + "created_at": "2026-08-14T21:24:09Z" + }, + { + "name": "Build Check (Windows x64)", + "conclusion": "success", + "branch": "main", + "created_at": "2026-08-14T21:23:46Z" + }, + { + "name": "Unused Dependencies", + "conclusion": "success", + "branch": "main", + "created_at": "2026-08-14T21:23:46Z" + }, + { + "name": "Lint Rust", + "conclusion": "success", + "branch": "main", + "created_at": "2026-08-14T21:23:46Z" + }, + { + "name": "Tests (Windows x64)", + "conclusion": "success", + "branch": "main", + "created_at": "2026-08-14T21:23:46Z" + } + ], + "all_green": true + }, + "git_log": [ + { + "hash": "c8b260f6", + "message": "chore: bump version to 0.2.1", + "date": "2026-08-14" + }, + { + "hash": "9f39c41c", + "message": "fix: track entry count manually in unpack_vsix (zip crate len() bug)", + "date": "2026-08-14" + }, + { + "hash": "b5f75c74", + "message": "Merge pull request #3 from airdropia/feat/bundle-node", + "date": "2026-08-14" + }, + { + "hash": "dcbacb39", + "message": "fix: unify Tauri package version to 0.2.0", + "date": "2026-08-14" + }, + { + "hash": "8f013532", + "message": "Merge pull request #2 from airdropia/feat/bundle-node", + "date": "2026-08-14" + }, + { + "hash": "e364a129", + "message": "style: format bundled Node candidate list", + "date": "2026-08-14" + }, + { + "hash": "27f27180", + "message": "fix: keep bundled Node resource optional during CI checks", + "date": "2026-08-14" + }, + { + "hash": "412cef61", + "message": "fix: correct Tauri resource JSON", + "date": "2026-08-14" + }, + { + "hash": "2da3591f", + "message": "feat: bundle Node.js runtime in Windows releases", + "date": "2026-08-14" + }, + { + "hash": "e41b3d9b", + "message": "Merge pull request #1 from airdropia/fix/extensions-build", + "date": "2026-08-14" + } + ], + "policies": { + "work_on_fork": true, + "fork_url": "https://github.com/airdropia/winsidex.git", + "original_repo": "Sidenai/sidex", + "original_repo_url": "https://github.com/Sidenai/sidex.git", + "policy": "All PRs, issues, and work go to fork (airdropia/winsidex). Do NOT create PRs on original repo (Sidenai/sidex). Upstream sync via rebase if needed.", + "ci_is_truth": true, + "local_builds": false, + "language": "Roman Urdu for user communication, English for code" + }, + "tech_stack": { + "frontend": "TypeScript, Vite 6, Monaco Editor", + "backend": "Rust, Tauri 2", + "terminal": "portable-pty", + "syntax": "vscode-textmate + vscode-oniguruma (WASM)", + "search": "dashmap + rayon + regex (parallel)", + "storage": "SQLite via rusqlite", + "extensions": "VSIX format, Open VSX marketplace" + }, + "target_platform": "Windows 10 x64 only", + "architecture": "Tauri 2 (Electron replacement, native webview)", + "license": "MIT" +}