diff --git a/.envrc b/.envrc index 2d3a786b3b65..5a04246e9123 100644 --- a/.envrc +++ b/.envrc @@ -1,7 +1,7 @@ strict_env -if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then - source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4=" +if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM=" fi use flake diff --git a/.github/workflows/bazel-build.yml b/.github/workflows/bazel-build.yml index 8b2977750083..9f15c2f39600 100644 --- a/.github/workflows/bazel-build.yml +++ b/.github/workflows/bazel-build.yml @@ -22,7 +22,7 @@ jobs: run: bazel run //:lint_bazel_files - name: Expose env variables run: | - cat << END > app/gui/.env.production + cat << END > app/common/.env.production ENSO_IDE_ENVIRONMENT="${{ vars.ENSO_CLOUD_ENVIRONMENT }}" ENSO_IDE_API_URL="${{ vars.ENSO_CLOUD_API_URL }}" ENSO_IDE_CHAT_URL="${{ vars.ENSO_CLOUD_CHAT_URL }}" diff --git a/.github/workflows/ide-packaging.yml b/.github/workflows/ide-packaging.yml index 1f578b327137..353fce415813 100644 --- a/.github/workflows/ide-packaging.yml +++ b/.github/workflows/ide-packaging.yml @@ -329,6 +329,7 @@ jobs: rm dist/backend/project-manager.tar - run: ./run ide build --backend-source local --gui-upload-artifact false env: + ENSO_IDE_HOST: ${{ vars.ENSO_HOST }} ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} ENSO_IDE_API_URL: ${{ vars.ENSO_CLOUD_API_URL }} ENSO_IDE_AUTH_ENDPOINT: ${{ vars.ENSO_CLOUD_AUTH_ENDPOINT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32953dfb04a5..41b4c5647c98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -442,6 +442,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./run ide upload --backend-source release --backend-release ${{env.ENSO_RELEASE_ID}} --sign-artifacts env: + ENSO_IDE_HOST: ${{ vars.ENSO_HOST }} ENSO_IDE_AG_GRID_LICENSE_KEY: ${{ vars.ENSO_AG_GRID_LICENSE_KEY }} ENSO_IDE_API_URL: ${{ vars.ENSO_CLOUD_API_URL }} ENSO_IDE_AUTH_ENDPOINT: ${{ vars.ENSO_CLOUD_AUTH_ENDPOINT }} diff --git a/.gitmodules b/.gitmodules index 71ffcda720d4..c83b636fd25a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "app/gui/.dev-env"] - path = app/gui/.dev-env +[submodule "app/common/.dev-env"] + path = app/common/.dev-env url = ../dev-env.git diff --git a/BUILD.bazel b/BUILD.bazel index c559da4ee4a2..e7beee0ddf78 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -42,7 +42,7 @@ write_source_files( buildifier( name = "lint_bazel_files", exclude_patterns = [ - "./app/gui/.env.bazel", + "./app/common/.env.bazel", ], lint_mode = "warn", mode = "diff", @@ -51,7 +51,7 @@ buildifier( buildifier( name = "format_bazel_files", exclude_patterns = [ - "./app/gui/.env.bazel", + "./app/common/.env.bazel", ], lint_mode = "fix", mode = "fix", diff --git a/CHANGELOG.md b/CHANGELOG.md index 35b06ef4fa3d..6498a077131b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ top. - [Context menu for connections][14325]. - [Warnings and Errors no longer become transparent][14388] +- [`--headless` flag to run a project without the User Interface][14310] +- [Preview for assets (text, main files of Projects, audio, video, images) in + Right Sidebar][14310] - ["Delete and Connect Around" option in node's menu][14403] - [GeoMap visualization is now working without need of Mapbox Token in environment][14429] @@ -63,6 +66,7 @@ [14209]: https://github.com/enso-org/enso/pull/14209 [14215]: https://github.com/enso-org/enso/pull/14215 [14270]: https://github.com/enso-org/enso/pull/14270 +[14310]: https://github.com/enso-org/enso/pull/14310 [14311]: https://github.com/enso-org/enso/pull/14311 [14267]: https://github.com/enso-org/enso/pull/14267 [14325]: https://github.com/enso-org/enso/pull/14325 diff --git a/app/gui/.dev-env b/app/common/.dev-env similarity index 100% rename from app/gui/.dev-env rename to app/common/.dev-env diff --git a/app/gui/.env.bazel b/app/common/.env.bazel similarity index 95% rename from app/gui/.env.bazel rename to app/common/.env.bazel index a13fb07cf112..a0d9ba3c11f3 100644 --- a/app/gui/.env.bazel +++ b/app/common/.env.bazel @@ -1,4 +1,4 @@ -# Bazel build step defaults to having all envs left as placeholders, so that they can be replaced later after the vite build step. +# Bazel build step defaults to having all envs left as placeholders, so that they can be replaced late after the vite build step. ENSO_IDE_AG_GRID_LICENSE_KEY = "((%__ENSO_IDE_AG_GRID_LICENSE_KEY__%))" ENSO_IDE_API_URL = "((%__ENSO_IDE_API_URL__%))" ENSO_IDE_CLOUD_BUILD = "((%__ENSO_IDE_CLOUD_BUILD__%))" diff --git a/app/gui/.env.development b/app/common/.env.development similarity index 100% rename from app/gui/.env.development rename to app/common/.env.development diff --git a/app/gui/.env.npekin b/app/common/.env.npekin similarity index 100% rename from app/gui/.env.npekin rename to app/common/.env.npekin diff --git a/app/gui/.env.pbuchu b/app/common/.env.pbuchu similarity index 100% rename from app/gui/.env.pbuchu rename to app/common/.env.pbuchu diff --git a/app/gui/.env.production b/app/common/.env.production similarity index 100% rename from app/gui/.env.production rename to app/common/.env.production diff --git a/app/gui/.env.staging b/app/common/.env.staging similarity index 100% rename from app/gui/.env.staging rename to app/common/.env.staging diff --git a/app/common/.env.testing b/app/common/.env.testing new file mode 100644 index 000000000000..f8981cf4a4ae --- /dev/null +++ b/app/common/.env.testing @@ -0,0 +1,7 @@ +ENSO_IDE_API_URL=https://mock +ENSO_IDE_HOST=https://ensoanalytics.com +ENSO_IDE_COGNITO_USER_POOL_ID=mars_AAAAAAAAA +ENSO_IDE_COGNITO_USER_POOL_WEB_CLIENT_ID=zzzzzzzzzzzzzzzzzzzzzzzzzz +ENSO_IDE_COMMIT_HASH=abcdef0 +ENSO_IDE_CLOUD_BUILD=false +ENSO_IDE_VERSION=0.0.1-testing diff --git a/app/common/BUILD.bazel b/app/common/BUILD.bazel index e5a55b1a4dae..a87e245158f8 100644 --- a/app/common/BUILD.bazel +++ b/app/common/BUILD.bazel @@ -1,3 +1,4 @@ +load("@aspect_bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") load("@aspect_rules_js//npm:defs.bzl", "npm_package") load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") load("@npm//:defs.bzl", "npm_link_all_packages", "npm_link_targets") @@ -36,3 +37,9 @@ npm_package( ], visibility = ["//visibility:public"], ) + +copy_to_bin( + name = "env_files", + srcs = glob([".env*"]), + visibility = ["//visibility:public"], +) diff --git a/app/common/package.json b/app/common/package.json index 72a87be5e123..3d235a835f9b 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -18,18 +18,21 @@ "main": "src/index.ts", "scripts": { "test:unit": "vitest run", - "compile": "tsc", + "typecheck": "tsc --noEmit", + "compile": "vite build", "lint": "eslint ./src --cache --max-warnings=0" }, "dependencies": { "@internationalized/date": "3.7.0", "@types/node": "catalog:", "is-network-error": "^1.1.0", + "vite": "catalog:", "vue": "catalog:", "zod": "catalog:" }, "devDependencies": { "@fast-check/vitest": "catalog:", + "vite-plugin-dts": "^4.5.4", "vitest": "catalog:" } } diff --git a/app/common/src/accessToken.ts b/app/common/src/accessToken.ts index 0796e98e1b2e..cb4f6b61a66e 100644 --- a/app/common/src/accessToken.ts +++ b/app/common/src/accessToken.ts @@ -16,3 +16,11 @@ export interface AccessToken { */ readonly expireAt: string } + +export interface RawAccessToken { + readonly access_token: string + readonly client_id: string + readonly refresh_token: string + readonly refresh_url: string + readonly expire_at: string +} diff --git a/app/common/src/config.ts b/app/common/src/config.ts new file mode 100644 index 000000000000..5cfac197df2a --- /dev/null +++ b/app/common/src/config.ts @@ -0,0 +1,97 @@ +/** + * @file This file defines a global environment config that can be used throughout the app. + * It is included directly into index.html and kept as a separate built artifact, so that + * we can easily replace its contents in a separate build postprocessing step in `BUILD.bazel`. + */ + +import { unsafeKeys } from './utilities/data/object.js' + +declare global { + interface ViteTypeOptions { + // strictImportMetaEnv: unknown + } + + // This needs to be ts-ignore, because not all packages have this key defined. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore This key is also defined in Vite. + type ImportMetaEnvFallbackKey = + 'strictImportMetaEnv' extends keyof ViteTypeOptions ? never : string + + interface ImportMetaEnv { + [key: ImportMetaEnvFallbackKey]: any + BASE_URL: string + MODE: string + DEV: boolean + PROD: boolean + SSR: boolean + } + + interface ImportMeta { + url: string + readonly env: ImportMetaEnv + } +} + +const processEnv = typeof process !== 'undefined' ? process.env : {} + +/** When running dev server, the config variables are grabbed from appropriate .env file. */ +export const $config = { + ENVIRONMENT: processEnv.ENSO_IDE_ENVIRONMENT ?? import.meta.env?.ENSO_IDE_ENVIRONMENT, + ENSO_HOST: + processEnv.ENSO_IDE_HOST ?? (import.meta.env?.ENSO_IDE_HOST || 'https://ensoanalytics.com'), + API_URL: processEnv.ENSO_IDE_API_URL ?? import.meta.env?.ENSO_IDE_API_URL, + SENTRY_DSN: processEnv.ENSO_IDE_SENTRY_DSN ?? import.meta.env?.ENSO_IDE_SENTRY_DSN, + STRIPE_KEY: processEnv.ENSO_IDE_STRIPE_KEY ?? import.meta.env?.ENSO_IDE_STRIPE_KEY, + AUTH_ENDPOINT: processEnv.ENSO_IDE_AUTH_ENDPOINT ?? import.meta.env?.ENSO_IDE_AUTH_ENDPOINT, + COGNITO_USER_POOL_ID: + processEnv.ENSO_IDE_COGNITO_USER_POOL_ID ?? import.meta.env?.ENSO_IDE_COGNITO_USER_POOL_ID, + COGNITO_USER_POOL_WEB_CLIENT_ID: + processEnv.ENSO_IDE_COGNITO_USER_POOL_WEB_CLIENT_ID ?? + import.meta.env?.ENSO_IDE_COGNITO_USER_POOL_WEB_CLIENT_ID, + GOOGLE_ANALYTICS_TAG: + processEnv.ENSO_IDE_GOOGLE_ANALYTICS_TAG ?? import.meta.env?.ENSO_IDE_GOOGLE_ANALYTICS_TAG, + COGNITO_DOMAIN: processEnv.ENSO_IDE_COGNITO_DOMAIN ?? import.meta.env?.ENSO_IDE_COGNITO_DOMAIN, + COGNITO_REGION: processEnv.ENSO_IDE_COGNITO_REGION ?? import.meta.env?.ENSO_IDE_COGNITO_REGION, + VERSION: processEnv.ENSO_IDE_VERSION ?? import.meta.env?.ENSO_IDE_VERSION, + COMMIT_HASH: processEnv.ENSO_IDE_COMMIT_HASH ?? import.meta.env?.ENSO_IDE_COMMIT_HASH, + YDOC_SERVER_URL: processEnv.ENSO_IDE_YDOC_SERVER_URL ?? import.meta.env?.ENSO_IDE_YDOC_SERVER_URL, + CLOUD_BUILD: processEnv.ENSO_IDE_CLOUD_BUILD ?? import.meta.env?.ENSO_IDE_CLOUD_BUILD, + AG_GRID_LICENSE_KEY: + processEnv.ENSO_IDE_AG_GRID_LICENSE_KEY ?? import.meta.env?.ENSO_IDE_AG_GRID_LICENSE_KEY, + GOOGLE_OAUTH_CLIENT_ID: + processEnv.ENSO_IDE_GOOGLE_OAUTH_CLIENT_ID ?? import.meta.env?.ENSO_IDE_GOOGLE_OAUTH_CLIENT_ID, + STRAVA_OAUTH_CLIENT_ID: + processEnv.ENSO_IDE_STRAVA_OAUTH_CLIENT_ID ?? import.meta.env?.ENSO_IDE_STRAVA_OAUTH_CLIENT_ID, + MS365_OAUTH_CLIENT_ID: + processEnv.ENSO_IDE_MS365_OAUTH_CLIENT_ID ?? import.meta.env?.ENSO_IDE_MS365_OAUTH_CLIENT_ID, + MAPBOX_API_TOKEN: + (typeof window === 'object' && + window && + 'api' in window && + typeof window.api === 'object' && + window.api && + 'mapBoxApiToken' in window.api && + typeof window.api.mapBoxApiToken === 'function' && + window.api?.mapBoxApiToken()) || + (processEnv.ENSO_IDE_MAPBOX_API_TOKEN ?? import.meta.env?.ENSO_IDE_MAPBOX_API_TOKEN), +} + +/** Sets the global configuration. */ +export function setConfig(config: typeof $config) { + for (const k of unsafeKeys(config)) { + if (config[k] === undefined) { + continue + } + // Special-case as ENSO_HOST may currently be an empty string when it is unset in CI. + if (k === 'ENSO_HOST' && config[k] === '') { + continue + } + $config[k] = config[k] + } +} + +// Undefined env variables are typed as `any`, but we want them to be `string | undefined`. +export type $Config = { + [K in keyof typeof $config]: unknown extends (typeof $config)[K] ? string | undefined + : (typeof $config)[K] +} diff --git a/app/common/src/index.ts b/app/common/src/constants.ts similarity index 100% rename from app/common/src/index.ts rename to app/common/src/constants.ts diff --git a/app/common/src/options.ts b/app/common/src/options.ts index dee348b4ba53..a611233fca8e 100644 --- a/app/common/src/options.ts +++ b/app/common/src/options.ts @@ -7,6 +7,7 @@ const DEFAULT_PORT = 8080 /** Schema for app-wide configuration options. */ export const OptionsSchema = z.object({ version: z.boolean().default(false), + headless: z.boolean().default(false), displayWindow: z.boolean().default(true), useServer: z.boolean().default(true), engineEnabled: z.boolean().default(true), diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index d15d7ee205a5..143993195f55 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -1,7 +1,13 @@ /** @file Type definitions common between all backends. */ import { z } from 'zod' import type { DownloadOptions } from '../download.js' -import { getText, resolveDictionary, type Replacements, type TextId } from '../text.js' +import { + getText, + resolveDictionary, + type DefaultGetText, + type Replacements, + type TextId, +} from '../text.js' import * as dateTime from '../utilities/data/dateTime.js' import * as newtype from '../utilities/data/newtype.js' import * as permissions from '../utilities/permissions.js' @@ -97,8 +103,6 @@ export interface Logger { readonly error: (message: unknown, ...optionalParams: unknown[]) => void } -export type GetText = (key: K, ...replacements: Replacements[K]) => string - /** The {@link Backend} variant. If a new variant is created, it should be added to this enum. */ export enum BackendType { local = 'local', @@ -632,9 +636,6 @@ export interface CreateCustomerPortalSessionResponse { readonly url: string | null } -/** Response from the "path/resolve" endpoint. */ -export interface PathResolveResponse extends Omit {} - /** Whether a type is `any`. */ type IsAny = 0 extends 1 & T ? true : false @@ -1686,13 +1687,13 @@ export class NotAuthorizedError extends NetworkError {} export abstract class Backend { abstract readonly type: BackendType abstract readonly baseUrl: URL - protected getText: GetText + protected getText: DefaultGetText private readonly client: HttpClient protected readonly downloader: (options: DownloadOptions) => void | Promise /** Create a {@link Backend}. */ constructor( - getText: GetText, + getText: DefaultGetText, client: HttpClient, downloader: (options: DownloadOptions) => void | Promise, ) { @@ -1705,7 +1706,7 @@ export abstract class Backend { * Set `this.getText`. This function is exposed rather than the property itself to make it clear * that it is intended to be mutable. */ - setGetText(getText: GetText) { + setGetText(getText: DefaultGetText) { this.getText = getText } @@ -1897,7 +1898,7 @@ export abstract class Backend { return (await this.resolveProjectAssetData(projectId, 'src/Main.enso', versionId)).text() } /** Resolve enso path to an asset */ - abstract resolveEnsoPath(path: EnsoPath): Promise + abstract resolveEnsoPath(path: EnsoPath): Promise /** Resolve the data of a project asset relative to the project root directory. */ abstract resolveProjectAssetData( projectId: ProjectId, diff --git a/app/common/src/services/HttpClient.ts b/app/common/src/services/HttpClient.ts index b885ee696d04..7ba7e0267296 100644 --- a/app/common/src/services/HttpClient.ts +++ b/app/common/src/services/HttpClient.ts @@ -148,6 +148,7 @@ export class HttpClient { } } + // On node.js, `navigator` seems to be defined, but `navigator.onLine` is always `undefined`. if (navigator.onLine !== undefined && !navigator.onLine) { return Promise.reject(new OfflineError('User is offline')) } @@ -164,21 +165,25 @@ export class HttpClient { })) as ResponseWithTypedJson & { readonly body: Method extends 'GET' | 'HEAD' ? null : NonNullable } - if (typeof document !== 'undefined') + if (typeof document !== 'undefined') { document.dispatchEvent(new Event(FETCH_SUCCESS_EVENT_NAME)) + } return response } catch (error) { // Even though the condition might seem always falsy, // offline mode might happen during the request // and this case need to be handled if (navigator.onLine !== undefined && !navigator.onLine) { - if (typeof document !== 'undefined') document.dispatchEvent(new Event(OFFLINE_EVENT_NAME)) + if (typeof document !== 'undefined') { + document.dispatchEvent(new Event(OFFLINE_EVENT_NAME)) + } throw new OfflineError('User is offline', { cause: error }) } if (isNetworkError(error)) { - if (typeof document !== 'undefined') + if (typeof document !== 'undefined') { document.dispatchEvent(new Event(FETCH_ERROR_EVENT_NAME)) + } throw new NetworkError(error.message, { cause: error }) } throw error diff --git a/app/common/src/services/LocalBackend.ts b/app/common/src/services/LocalBackend.ts index 11692905e75e..072f4dc55f00 100644 --- a/app/common/src/services/LocalBackend.ts +++ b/app/common/src/services/LocalBackend.ts @@ -6,8 +6,9 @@ * the API. */ import { markRaw } from 'vue' +import { PRODUCT_NAME } from '../constants.js' import type { DownloadOptions } from '../download.js' -import { PRODUCT_NAME } from '../index.js' +import type { DefaultGetText } from '../text.js' import { toReadableIsoString } from '../utilities/data/dateTime.js' import { tryGetMessage } from '../utilities/errors.js' import { @@ -87,7 +88,7 @@ export class LocalBackend extends backend.Backend { /** Create a {@link LocalBackend}. */ constructor( - getText: backend.GetText, + getText: DefaultGetText, projectManagerInstance: ProjectManager, client = new HttpClient(), downloader: (options: DownloadOptions) => void | Promise, @@ -777,8 +778,8 @@ export class LocalBackend extends backend.Backend { } /** Resolve path to asset. In case of LocalBackend, this is just the filesystem path. */ - override resolveEnsoPath(path: backend.EnsoPath): Promise { - const { directoryPath } = getDirectoryAndName(Path(String(path))) + override resolveEnsoPath(path: backend.EnsoPath): Promise { + const { directoryPath } = getDirectoryAndName(Path(path as string)) return this.findAsset(directoryPath, 'ensoPath', path) } @@ -874,7 +875,6 @@ export class LocalBackend extends backend.Backend { assetId: backend.AssetId, localProjectId: backend.ProjectId, parentDirectoryId: backend.DirectoryId, - baseUrl: URL, defaultHeaders: Record, ): Promise { const localProjectDirectory = backend.extractTypeAndPath(localProjectId).path @@ -882,7 +882,6 @@ export class LocalBackend extends backend.Backend { assetId, parentDirectoryId, directory: localProjectDirectory, - baseUrl: baseUrl.toString(), }).toString() const response = await this.post( new URL(`/api/watcher/start?${queryString}`, location.href).toString(), diff --git a/app/common/src/services/RemoteBackend.ts b/app/common/src/services/RemoteBackend.ts index c0046a8e2331..f10703fe66ec 100644 --- a/app/common/src/services/RemoteBackend.ts +++ b/app/common/src/services/RemoteBackend.ts @@ -7,7 +7,9 @@ */ import { markRaw } from 'vue' import { z } from 'zod' +import { $config } from '../config.js' import type { DownloadOptions } from '../download.js' +import type { DefaultGetText } from '../text.js' import { delay } from '../utilities/async.js' import * as objects from '../utilities/data/object.js' import * as detect from '../utilities/detect.js' @@ -15,7 +17,7 @@ import { getFileName, getFolderPath } from '../utilities/file.js' import * as backend from './Backend.js' import * as remoteBackendPaths from './Backend/remoteBackendPaths.js' import type { HttpClient } from './HttpClient.js' -import { extractIdFromDirectoryId, organizationIdToDirectoryId } from './RemoteBackend/ids.js' +import { organizationIdToDirectoryId } from './RemoteBackend/ids.js' /** HTTP status indicating that the resource does not exist. */ const STATUS_NOT_FOUND = 404 @@ -26,22 +28,52 @@ const EXPORT_STATUS_INTERVAL_MS = 5_000 /** The interval between checks for the import status. */ const IMPORT_STATUS_INTERVAL_MS = 5_000 +export type DownloadCloudProjectFunction = ( + this: RemoteBackend, + params: { + downloadUrl: backend.HttpsUrl + projectId: backend.ProjectId + }, +) => Promise<{ + readonly projectRootDirectory: string + readonly parentDirectory: string +}> + +export type GetProjectArchiveFunction = ( + this: RemoteBackend, + directoryId: backend.DirectoryId, + fileName: string, +) => Promise + /** Class for sending requests to the Cloud backend API endpoints. */ export class RemoteBackend extends backend.Backend { static readonly type = backend.BackendType.remote override readonly type = RemoteBackend.type - override readonly baseUrl: URL + override readonly baseUrl: URL = new URL( + $config.API_URL ?? '', + typeof location !== 'undefined' ? location.href : 'https://example.com', + ) private user: objects.Mutable | null = null + private readonly downloadCloudProject: DownloadCloudProjectFunction + readonly getProjectArchive: GetProjectArchiveFunction /** Create a {@link RemoteBackend}. */ - constructor( - getText: backend.GetText, - client: HttpClient, - downloader: (options: DownloadOptions) => void | Promise, - baseUrl: URL, - ) { + constructor({ + getText, + client, + downloader, + downloadCloudProject, + getProjectArchive, + }: { + getText: DefaultGetText + client: HttpClient + downloader: (options: DownloadOptions) => void | Promise + downloadCloudProject: DownloadCloudProjectFunction + getProjectArchive: GetProjectArchiveFunction + }) { super(getText, client, downloader) - this.baseUrl = baseUrl + this.downloadCloudProject = downloadCloudProject + this.getProjectArchive = getProjectArchive } /** The path to the root directory of this {@link Backend}. */ @@ -879,7 +911,7 @@ export class RemoteBackend extends backend.Backend { */ override async uploadFileStart( body: backend.UploadFileRequestParams, - file: Blob, + file: File, abort?: AbortSignal, ): Promise { const path = remoteBackendPaths.UPLOAD_FILE_START_PATH @@ -889,7 +921,7 @@ export class RemoteBackend extends backend.Backend { } const response = await this.post(path, requestBody, { abort }) if (!response.ok) { - return await this.throw(response, 'uploadFileStartBackendError') + return await this.throw(response, 'uploadFileStartBackendError', body.fileName) } else { return await response.json() } @@ -928,7 +960,7 @@ export class RemoteBackend extends backend.Backend { const path = remoteBackendPaths.UPLOAD_FILE_END_PATH const response = await this.post(path, body, { abort }) if (!response.ok) { - return await this.throw(response, 'uploadFileEndBackendError') + return await this.throw(response, 'uploadFileEndBackendError', body.fileName) } else { const result = await response.json() if (result.jobId != null) { @@ -1414,54 +1446,20 @@ export class RemoteBackend extends backend.Backend { /** Download the project to a temporary location. */ async downloadProject(id: backend.ProjectId) { - /** The type of the response body of this endpoint. */ - interface ResponseBody { - readonly projectRootDirectory: string - readonly parentDirectory: string - } const details = await this.getProjectDetails(id, true) - if (details.url == null) { return this.throw(null, 'getProjectDetailsBackendError') } - - const queryString = new URLSearchParams({ + const responseBody = await this.downloadCloudProject({ downloadUrl: details.url, projectId: id, }) - - const response = await this.get( - new URL(`/api/cloud/download-project?${queryString}`, location.href).toString(), - ) - if (!response.ok) { - return await this.throw(response, 'resolveProjectAssetPathBackendError') - } - - const responseBody = await response.json() - return { projectRootId: backend.DirectoryId(`directory-${responseBody.projectRootDirectory}`), parentId: backend.DirectoryId(`directory-${responseBody.parentDirectory}`), } } - /** Get the enso-project archive contents. */ - async getProjectArchive(directoryId: backend.DirectoryId, fileName: string): Promise { - const queryString = new URLSearchParams({ - directory: extractIdFromDirectoryId(directoryId), - }).toString() - const response = await this.get( - new URL(`/api/cloud/get-project-archive?${queryString}`, location.href).toString(), - ) - if (!response.ok) { - return await this.throw(response, 'resolveProjectAssetPathBackendError') - } - - const responseBody = await response.arrayBuffer() - - return new File([responseBody], fileName) - } - /** Fetch the URL of the customer portal. */ override async createCustomerPortalSession() { // A dummy query parameter is required due to issues with backend validation. @@ -1477,15 +1475,16 @@ export class RemoteBackend extends backend.Backend { } /** Resolve asset metadata from an enso path. */ - override async resolveEnsoPath(path: backend.EnsoPath): Promise { + override async resolveEnsoPath(path: backend.EnsoPath): Promise { const effectivePath = backend.EnsoPath(path.replace(/%20/g, ' ')) - const response = await this.get>( - remoteBackendPaths.RESOLVE_ENSO_PATH, - { path: effectivePath }, - ) + const response = await this.get(remoteBackendPaths.RESOLVE_ENSO_PATH, { + path: effectivePath, + }) - if (!response.ok) return this.throw(response, 'resolveEnsoPathBackendError') - return await response.json() + if (!response.ok) return this.throw(response, 'resolveEnsoPathBackendError', path) + const asset = await response.json() + // `ensoPath` is currently necessary; the response (supposedly) does not include it. + return this.normalizeAsset({ ...asset, ensoPath: path }, null) } /** diff --git a/app/common/src/text.ts b/app/common/src/text.ts index 40f3d77d19a0..cbb8b62010f1 100644 --- a/app/common/src/text.ts +++ b/app/common/src/text.ts @@ -185,6 +185,10 @@ interface PlaceholderOverrides { readonly welcomeToTeam: [organizationName: string] readonly invitationText: [organizationName: string] + readonly resolveEnsoPathBackendError: [ensoPath: string] + readonly uploadFileStartBackendError: [fileName: string] + readonly uploadFileEndBackendError: [fileName: string] + readonly youCanCreateXMoreApiKeys: [apiKeysLeft: number] readonly deleteApiKeyConfirmation: [tokenName: string] } @@ -216,6 +220,19 @@ export type GetText = ( ...replacements: Replacements[K] ) => string +/** + * A function that gets localized text for a given key, with optional replacements. + * @param key - The key of the text to get. + * @param replacements - The replacements to insert into the text. + * If the text contains placeholders like `$0`, `$1`, etc., + * they will be replaced with the corresponding replacement. + */ +export type DefaultGetText = (key: K, ...replacements: Replacements[K]) => string + +export const defaultGetText: DefaultGetText = (key, ...replacements) => { + return getText(TEXTS.english, key, ...replacements) +} + /** Resolves the language texts based on the user's preferred language. */ export function resolveUserLanguage(): Language { const locale = navigator.language diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index c1d931ad1e0b..a545f2285a51 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -157,9 +157,9 @@ "openProjectMissingCredentialsBackendError": "Could not open project '$0': Missing credentials", "updateProjectBackendError": "Could not update project '$0'", "uploadFileBackendError": "Could not upload file", - "uploadFileStartBackendError": "Could not begin uploading large file", + "uploadFileStartBackendError": "Could not begin uploading large file '$0'", "uploadFileChunkBackendError": "Could not upload chunk of large file", - "uploadFileEndBackendError": "Could not finish uploading large file", + "uploadFileEndBackendError": "Could not finish uploading large file '$0'", "uploadImageBackendError": "Could not upload image", "updateFileNotImplementedBackendError": "Files currently cannot be renamed on the Cloud backend", "uploadFileWithNameBackendError": "Could not upload file '$0'", @@ -191,7 +191,7 @@ "duplicateLabelError": "This label already exists.", "emptyStringError": "This value must not be empty.", "resolveProjectAssetPathBackendError": "Could not get asset", - "resolveEnsoPathBackendError": "Could not resolve enso path", + "resolveEnsoPathBackendError": "Could not resolve enso path '$0'", "directoryAssetType": "folder", "directoryDoesNotExistError": "Unable to find directory. Does it exist?", "cannotEditCredentialError": "Editing credentials is currently not supported. Please create a new one.", diff --git a/app/common/src/utilities/file.ts b/app/common/src/utilities/file.ts index cddbdf177c0e..5036b98af14d 100644 --- a/app/common/src/utilities/file.ts +++ b/app/common/src/utilities/file.ts @@ -14,9 +14,7 @@ export interface InputFilesOptions { readonly multiple?: boolean } -/** - * Open a file-selection dialog and read the file selected by the user. - */ +/** Open a file-selection dialog and read the file selected by the user. */ export function readUserSelectedFile(options: InputFilesOptions = {}) { return new Promise((resolve, reject) => { const input = document.createElement('input') diff --git a/app/common/tsconfig.json b/app/common/tsconfig.json index 1d3dc85c9e00..3a0833b2ed30 100644 --- a/app/common/tsconfig.json +++ b/app/common/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src", "./src/text/english.json"], + "include": ["./src", "./src/text/english.json"], "exclude": [] } diff --git a/app/common/vite.config.ts b/app/common/vite.config.ts new file mode 100644 index 000000000000..cce7c9e9e47d --- /dev/null +++ b/app/common/vite.config.ts @@ -0,0 +1,26 @@ +import { globSync } from 'node:fs' +import { extname, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +// https://vitejs.dev/config/ +export default defineConfig({ + mode: process.env.MODE, + plugins: [dts()], + build: { + lib: { + entry: Object.fromEntries( + globSync('src/**/*.ts').map((path) => [ + relative('src', path.slice(0, path.length - extname(path).length)), + path, + ]), + ), + formats: ['es'], + }, + minify: false, + }, + cacheDir: fileURLToPath(new URL('../../node_modules/.cache/vite', import.meta.url)), + envPrefix: 'ENSO_IDE_', + logLevel: 'info', +}) diff --git a/app/electron-client/electron-builder-config.ts b/app/electron-client/electron-builder-config.ts index 2cf1a6794184..a44fdb9eb250 100644 --- a/app/electron-client/electron-builder-config.ts +++ b/app/electron-client/electron-builder-config.ts @@ -15,7 +15,7 @@ import * as electronNotarize from '@electron/notarize' import * as electronBuilder from 'electron-builder' import yargs from 'yargs' -import * as common from 'enso-common' +import * as common from 'enso-common/src/constants' import computeHashes from './tasks/computeHashes' import signArchivesMacOs from './tasks/signArchivesMacOs' diff --git a/app/electron-client/package.json b/app/electron-client/package.json index 12b6c2f12cc6..b655a0d3dac9 100644 --- a/app/electron-client/package.json +++ b/app/electron-client/package.json @@ -31,6 +31,7 @@ "semver": "^7.6.3", "tar-fs": "^3.0.9", "tar-stream": "^3.1.7", + "vitest": "catalog:", "yargs": "17.6.2", "yauzl": "^3.2.0", "ydoc-server": "workspace:*", diff --git a/app/electron-client/playwright.config.ts b/app/electron-client/playwright.config.ts index 4c0ed816e2e2..675f6804f0dd 100644 --- a/app/electron-client/playwright.config.ts +++ b/app/electron-client/playwright.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from 'playwright/test' export default defineConfig({ testDir: './tests', + // Headless tests are run via vitest, not playwright, so we ignore them here. + testIgnore: ['headless/**'], forbidOnly: !!process.env.CI, workers: 1, timeout: 180000, diff --git a/app/electron-client/src/archive.ts b/app/electron-client/src/archive.ts index 28ea8227afca..69e58ab9816b 100644 --- a/app/electron-client/src/archive.ts +++ b/app/electron-client/src/archive.ts @@ -17,7 +17,7 @@ export interface ArchiveEntryMetadata extends FileDataInput { } export interface ArchiveBuilder { - readonly stream: Stream + readonly stream: Readable readonly addFile: ( source: Buffer | _Readable.Stream | Stream | string, data: ArchiveEntryMetadata, diff --git a/app/electron-client/src/assetManagement.ts b/app/electron-client/src/assetManagement.ts new file mode 100644 index 000000000000..6767705f3bf4 --- /dev/null +++ b/app/electron-client/src/assetManagement.ts @@ -0,0 +1,19 @@ +import type { ProjectId } from 'enso-common/src/services/Backend' +import { mkdir, rm } from 'node:fs/promises' +import type { IncomingMessage } from 'node:http' +import * as https from 'node:https' +import * as path from 'node:path' +import { getProjectsDirectory, unpackBundle } from 'project-manager-shim' + +/** Download a project from the cloud. */ +export async function downloadCloudProject(downloadUrl: string, projectId: ProjectId) { + const response = await new Promise((resolve) => https.get(downloadUrl, resolve)) + const projectsDirectory = getProjectsDirectory() + const parentDirectory = path.join(projectsDirectory, `cloud-${projectId}`) + const projectRootDirectory = path.join(parentDirectory, 'project_root') + + await rm(parentDirectory, { recursive: true, force: true, maxRetries: 3 }) + await mkdir(projectRootDirectory, { recursive: true }) + await unpackBundle(response, projectRootDirectory) + return { projectRootDirectory, parentDirectory } +} diff --git a/app/electron-client/src/authentication.ts b/app/electron-client/src/authentication.ts index 47f25f5ebefa..11a37bda39ae 100644 --- a/app/electron-client/src/authentication.ts +++ b/app/electron-client/src/authentication.ts @@ -40,8 +40,8 @@ * credentials. * * To redirect the user from the IDE to an external source: - * 1. Register a listener for {@link ipc.Channel.openUrlInSystemBrowser} IPC events. - * 2. Emit an {@link ipc.Channel.openUrlInSystemBrowser} event. The listener registered in step + * 1. Register a listener for {@link Channel.openUrlInSystemBrowser} IPC events. + * 2. Emit an {@link Channel.openUrlInSystemBrowser} event. The listener registered in step * 1 will use the {@link opener} library to open the event's {@link URL} * argument in the system web browser, in a cross-platform way. * @@ -58,7 +58,7 @@ * To prepare the application to handle deep links: * - Register a custom URL protocol scheme with the OS (c.f., `electron-builder-config.ts`). * - Define a listener for Electron `OPEN_URL_EVENT`s. - * - Define a listener for {@link ipc.Channel.openDeepLink} events (c.f., `preload.ts`). + * - Define a listener for {@link Channel.openDeepLink} events (c.f., `preload.ts`). * * Then when the user clicks on a deep link from an external source to the IDE: * - The OS redirects the user to the application. @@ -66,28 +66,26 @@ * - The `OPEN_URL_EVENT` listener checks if the {@link URL} is a deep link. * - If the {@link URL} is a deep link, the `OPEN_URL_EVENT` listener prevents Electron from * handling the event. - * - The `OPEN_URL_EVENT` listener then emits an {@link ipc.Channel.openDeepLink} event. - * - The {@link ipc.Channel.openDeepLink} listener registered by the dashboard receives the event. + * - The `OPEN_URL_EVENT` listener then emits an {@link Channel.openDeepLink} event. + * - The {@link Channel.openDeepLink} listener registered by the dashboard receives the event. * Then it parses the {@link URL} from the event's {@link URL} argument. Then it uses the * {@link URL} to redirect the user to the dashboard, to the page specified in the {@link URL}'s * `pathname`. */ -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' - -import * as electron from 'electron' +import { CREDENTIALS_PATH } from '@/paths' +import type { BrowserWindow } from 'electron' +import type { AccessToken, RawAccessToken } from 'enso-common/src/accessToken' +import { DEEP_LINK_SCHEME } from 'enso-common/src/constants' +import { setDefaultResultOrder } from 'node:dns' +import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' import opener from 'opener' +import type { Electron } from './electron.js' +import { Channel } from './ipc.js' +import { registerUrlCallback } from './urlAssociations.js' -import * as common from 'enso-common' -import type * as accessToken from 'enso-common/src/accessToken' - -import * as ipc from '@/ipc' -import * as urlAssociations from '@/urlAssociations' - -// ======================================== -// === Initialize Authentication Module === -// ======================================== +/** How much longer the access token should be valid for before refreshing. */ +const REFRESH_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes /** * Configure all the functionality that must be set up in the Electron app to support @@ -97,70 +95,117 @@ import * as urlAssociations from '@/urlAssociations' * does not use the `window` until after it is initialized, so while the lambda may return `null` in * theory, it never will in practice. */ -export function initAuthentication(window: () => electron.BrowserWindow) { +export function initAuthentication(electron: Electron, window: () => BrowserWindow) { // Listen for events to open a URL externally in a browser the user trusts. This is used for // OAuth authentication, both for trustworthiness and for convenience (the ability to use the // browser's saved passwords). - electron.ipcMain.on(ipc.Channel.openUrlInSystemBrowser, (_event, url: string) => { + electron.ipcMain.on(Channel.openUrlInSystemBrowser, (_event, url: string) => { console.log(`Opening URL '${url}' in the default browser.`) opener(url) }) // Listen for events to handle deep links. - urlAssociations.registerUrlCallback((url) => { + registerUrlCallback(electron, (url) => { console.log(`Received 'open-url' event for '${url.toString()}'.`) - if (url.protocol !== `${common.DEEP_LINK_SCHEME}:`) { + if (url.protocol !== `${DEEP_LINK_SCHEME}:`) { console.error(`'${url.toString()}' is not a deep link, ignoring.`) } else { console.log(`'${url.toString()}' is a deep link, sending to renderer.`) - window().webContents.send(ipc.Channel.openDeepLink, url.toString()) + window().webContents.send(Channel.openDeepLink, url.toString()) } }) // Listen for events to save the given user credentials to `~/.enso/credentials`. - electron.ipcMain.on( - ipc.Channel.saveAccessToken, - (event, accessTokenPayload: accessToken.AccessToken | null) => { - event.preventDefault() + electron.ipcMain.on(Channel.saveAccessToken, (event, accessTokenPayload: AccessToken | null) => { + event.preventDefault() + saveAccessToken(accessTokenPayload) + }) +} - /** Home directory for the credentials file. */ - const credentialsDirectoryName = `.${common.PRODUCT_NAME.toLowerCase()}` - /** File name of the credentials file. */ - const credentialsFileName = 'credentials' - /** System agnostic credentials directory home path. */ - const credentialsHomePath = path.join(os.homedir(), credentialsDirectoryName) +/** Read the access token stored in the credentials file. */ +export function readAccessToken(): AccessToken | undefined { + try { + const raw: RawAccessToken = JSON.parse(readFileSync(CREDENTIALS_PATH, { encoding: 'utf-8' })) + return { + accessToken: raw.access_token, + clientId: raw.client_id, + refreshToken: raw.refresh_token, + refreshUrl: raw.refresh_url, + expireAt: raw.expire_at, + } + } catch { + return + } +} - if (accessTokenPayload == null) { - try { - fs.unlinkSync(path.join(credentialsHomePath, credentialsFileName)) - } catch { - // Ignored, most likely the path does not exist. - } - } else { - fs.mkdir(credentialsHomePath, { recursive: true }, (error) => { - if (error) { - console.error(`Could not create '${credentialsDirectoryName}' directory.`) - } else { - fs.writeFile( - path.join(credentialsHomePath, credentialsFileName), - JSON.stringify({ - /* eslint-disable camelcase */ - client_id: accessTokenPayload.clientId, - access_token: accessTokenPayload.accessToken, - refresh_token: accessTokenPayload.refreshToken, - refresh_url: accessTokenPayload.refreshUrl, - expire_at: accessTokenPayload.expireAt, - /* eslint-enable camelcase */ - }), - (innerError) => { - if (innerError) { - console.error(`Could not write to '${credentialsFileName}' file.`) - } - }, - ) - } - }) - } - }, +/** Save the access token to the credentials file. */ +export function saveAccessToken(accessToken: AccessToken | null): void { + if (accessToken === null) { + try { + unlinkSync(CREDENTIALS_PATH) + } catch { + // Ignored, most likely the path does not exist. + } + return + } + mkdirSync(dirname(CREDENTIALS_PATH), { recursive: true }) + writeFileSync( + CREDENTIALS_PATH, + JSON.stringify({ + /* eslint-disable camelcase */ + client_id: accessToken.clientId, + access_token: accessToken.accessToken, + refresh_token: accessToken.refreshToken, + refresh_url: accessToken.refreshUrl, + expire_at: accessToken.expireAt, + /* eslint-enable camelcase */ + }), ) } + +interface AuthenticationResultType { + readonly AccessToken?: string | undefined + readonly ExpiresIn?: number | undefined + readonly TokenType?: string | undefined + readonly RefreshToken?: string | undefined + readonly IdToken?: string | undefined +} + +/** Get an up-to-date access token, refreshing it if necessary. */ +export async function getUpToDateAccessToken(): Promise { + // This function MUST be kept in sync with the original source at: + // distribution/lib/Standard/Base/0.0.0-dev/src/Enso_Cloud/Internal/Authentication.enso + const accessToken = readAccessToken() + if (!accessToken) { + throw new Error('You are not logged in. Please open in windowed mode and login.') + } + if (Number(Date.now()) < Number(new Date(accessToken.expireAt)) - REFRESH_THRESHOLD_MS) { + return accessToken.accessToken + } + // I don't know why this works + setDefaultResultOrder('ipv6first') + const response = await fetch(accessToken.refreshUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-amz-json-1.1', + 'X-Amz-Target': 'AWSCognitoIdentityProviderService.InitiateAuth', + }, + body: JSON.stringify({ + ClientId: accessToken.clientId, + AuthFlow: 'REFRESH_TOKEN_AUTH', + AuthParameters: { REFRESH_TOKEN: accessToken.refreshToken }, + }), + }) + if (!response.ok) { + throw new Error(`Authentication token refresh failed with status ${response.status}`) + } + const result: AuthenticationResultType = await response + .json() + .then((res) => res.AuthenticationResult) + const newAccessToken = result?.AccessToken + if (!newAccessToken) { + throw new Error('Failed to refresh access token.') + } + saveAccessToken({ ...accessToken, accessToken: newAccessToken }) + return newAccessToken +} diff --git a/app/electron-client/src/backend.ts b/app/electron-client/src/backend.ts new file mode 100644 index 000000000000..dacb8664ef7d --- /dev/null +++ b/app/electron-client/src/backend.ts @@ -0,0 +1,45 @@ +import { downloadCloudProject } from '@/assetManagement' +import { getUpToDateAccessToken } from '@/authentication' +import { HttpClient } from 'enso-common/src/services/HttpClient' +import { RemoteBackend } from 'enso-common/src/services/RemoteBackend' +import { extractIdFromDirectoryId } from 'enso-common/src/services/RemoteBackend/ids' +import { defaultGetText } from 'enso-common/src/text' +import path from 'node:path' +import { createBundle } from 'project-manager-shim' +import buildInfo from '../buildInfo' + +/** Create a remote backend */ +export async function createRemoteBackend() { + const accessToken = await getUpToDateAccessToken() + if (!accessToken) { + throw new Error('No access token found for remote backend.') + } + const sessionId = crypto.randomUUID() + const httpClient = new HttpClient({ + 'x-enso-ide-version': buildInfo.version, + 'x-enso-session-id': sessionId, + /** + * For compatibility with backend versioned endpoints. The new project logs endpoint + * checks for date strings that are at least `2025-01-16`. + */ + 'x-enso-version': '2025-01-16', + }) + httpClient.setSessionToken(accessToken) + const downloader = () => { + throw new Error( + 'Cannot download files in headless mode. If you see this message, please report a bug, as it means this functionality is now required.', + ) + } + return new RemoteBackend({ + getText: defaultGetText, + client: httpClient, + downloader, + downloadCloudProject: (params) => downloadCloudProject(params.downloadUrl, params.projectId), + getProjectArchive: async (directoryId, fileName) => { + const parentDir = extractIdFromDirectoryId(directoryId) + const projectDir = path.join(parentDir, 'project_root') + const projectBundle = await createBundle(projectDir) + return new File([projectBundle], fileName) + }, + }) +} diff --git a/app/electron-client/src/configParser.ts b/app/electron-client/src/configParser.ts index 7d7e95dafc28..5e74d0af57b2 100644 --- a/app/electron-client/src/configParser.ts +++ b/app/electron-client/src/configParser.ts @@ -12,6 +12,11 @@ import { // CLI-only metadata defining flags and descriptions. const OPTIONS_META: Readonly> = { version: { flag: '-v, --version', description: 'Show version and exit' }, + headless: { + flag: '--headless', + description: + 'Run in headless mode (single execution). Requires `--startup.project `.', + }, displayWindow: { flag: '--no-window', description: 'Run server only, no GUI', diff --git a/app/electron-client/src/electron.ts b/app/electron-client/src/electron.ts new file mode 100644 index 000000000000..bc4d6956ca0f --- /dev/null +++ b/app/electron-client/src/electron.ts @@ -0,0 +1,163 @@ +import { Channel } from '@/ipc' +import { dialog, ipcMain, shell, type BrowserWindow } from 'electron' +import { download } from 'electron-dl' +import type { DownloadUrlOptions } from 'enso-common/src/download' +import { unlinkSync } from 'node:fs' +import { basename, dirname, extname } from 'node:path' +import { importProjectFromPath, isProjectBundle, isProjectRoot } from 'project-manager-shim' +import { toElectronFileFilter, type FileFilter } from './fileBrowser' + +export type Electron = typeof import('electron') + +/** + * Set Chrome options based on the app configuration. For comprehensive list of available + * Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. + */ +export function setChromeOptions(electron: Electron) { + // Needed to accept localhost self-signed cert + electron.app.commandLine.appendSwitch('ignore-certificate-errors') + // Enable native CPU-mappable GPU memory buffer support on Linux. + electron.app.commandLine.appendSwitch('enable-native-gpu-memory-buffers') + // Override the list of blocked GPU hardware, allowing for GPU acceleration on system configurations + // that do not inherently support it. It should be noted that some hardware configurations may have + // driver issues that could result in rendering discrepancies. Despite this, the utilization of GPU + // acceleration has the potential to significantly enhance the performance of the application in our + // specific use cases. This behavior can be observed in the following example: + // https://groups.google.com/a/chromium.org/g/chromium-dev/c/09NnO6jYT6o. + electron.app.commandLine.appendSwitch('ignore-gpu-blocklist') +} + +/** Register keyboard shortcuts that should be handled by Electron. */ +export function registerShortcuts(electron: Electron) { + electron.app.on('web-contents-created', (_webContentsCreatedEvent, webContents) => { + webContents.on('before-input-event', (_beforeInputEvent, input) => { + const { code, alt, control, shift, meta, type } = input + if (type === 'keyDown') { + const focusedWindow = electron.BrowserWindow.getFocusedWindow() + if (focusedWindow) { + if (control && alt && shift && !meta && code === 'KeyI') { + focusedWindow.webContents.toggleDevTools() + } + if (control && alt && shift && !meta && code === 'KeyR') { + focusedWindow.reload() + } + } + } + }) + }) +} + +/** + * Initialize Inter-Process Communication between the Electron application and the served + * website. + */ +export function initIpc(window: BrowserWindow | null) { + ipcMain.on(Channel.error, (_event, data) => { + console.error(...data) + }) + ipcMain.on(Channel.warn, (_event, data) => { + console.warn(...data) + }) + ipcMain.on(Channel.log, (_event, data) => { + console.log(...data) + }) + ipcMain.on(Channel.info, (_event, data) => { + console.info(...data) + }) + ipcMain.on( + Channel.importProjectFromPath, + (event, path: string, directory: string | null, title: string) => { + const directoryParams = directory == null ? [] : [directory] + const info = importProjectFromPath(path, ...directoryParams, title) + event.reply(Channel.importProjectFromPath, path, info) + }, + ) + ipcMain.handle(Channel.downloadURL, async (_event, options: DownloadUrlOptions) => { + const { url, path, name, shouldUnpackProject, showFileDialog } = options + // This should never happen, but we'll check for it anyway. + if (!window) { + throw new Error('Window is not available.') + } + + await download(window, url, { + ...(path != null ? { directory: path } : {}), + ...(name != null ? { filename: name } : {}), + saveAs: showFileDialog != null ? showFileDialog : path == null, + onCompleted: (file) => { + const path = file.path + const filenameRaw = basename(path) + + try { + if (isProjectBundle(path) || isProjectRoot(path)) { + if (!shouldUnpackProject) { + return + } + // in case we're importing a project bundle, we need to remove the extension + // from the filename + const filename = filenameRaw.replace(extname(filenameRaw), '') + const directory = dirname(path) + + importProjectFromPath(path, directory, filename) + unlinkSync(path) + } + } catch (error) { + console.error('Error downloading URL', error) + } + }, + }) + + return + }) + ipcMain.on(Channel.showItemInFolder, (_event, fullPath: string) => { + shell.showItemInFolder(fullPath) + }) + ipcMain.handle( + Channel.openFileBrowser, + async ( + _event, + kind: 'default' | 'directory' | 'file' | 'filePath', + defaultPath?: string, + filters?: FileFilter[], + ) => { + console.log('Request for opening browser for ', kind, defaultPath, JSON.stringify(filters)) + let retval = null + if (kind === 'filePath') { + // "Accept", as the file won't be created immediately. + const { canceled, filePath } = await dialog.showSaveDialog({ + buttonLabel: 'Accept', + filters: filters?.map(toElectronFileFilter) ?? [], + ...(defaultPath != null ? { defaultPath } : {}), + }) + if (!canceled) { + retval = [filePath] + } + } else { + /** Helper for `showOpenDialog`, which has weird types by default. */ + type Properties = ('openDirectory' | 'openFile')[] + const properties: Properties = + kind === 'file' ? ['openFile'] + : kind === 'directory' ? ['openDirectory'] + : process.platform === 'darwin' ? ['openFile', 'openDirectory'] + : ['openFile'] + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties, + filters: filters?.map(toElectronFileFilter) ?? [], + ...(defaultPath != null ? { defaultPath } : {}), + }) + if (!canceled) { + retval = filePaths + } + } + return retval + }, + ) + + // Handling navigation events from renderer process + ipcMain.on(Channel.goBack, () => { + window?.webContents.navigationHistory.goBack() + }) + + ipcMain.on(Channel.goForward, () => { + window?.webContents.navigationHistory.goForward() + }) +} diff --git a/app/electron-client/src/fileAssociations.ts b/app/electron-client/src/fileAssociations.ts index f47fa7a75ef7..c15af9be7ee0 100644 --- a/app/electron-client/src/fileAssociations.ts +++ b/app/electron-client/src/fileAssociations.ts @@ -6,31 +6,18 @@ * process, and launching new instances of the IDE when necessary. The module also exports * constants related to file associations and project handling. */ +import type { Electron } from '@/electron' +import type { Event } from 'electron' +import * as common from 'enso-common/src/constants' import * as fsSync from 'node:fs' import * as pathModule from 'node:path' -import process from 'node:process' - -import * as electron from 'electron' -import electronIsDev from 'electron-is-dev' - -import * as common from 'enso-common' - import * as project from 'project-manager-shim' - import * as fileAssociations from '../fileAssociations' export * from '../fileAssociations' -// ================= -// === Constants === -// ================= - /** Returned by {@link String.indexOf} when the substring was not found. */ const NOT_FOUND = -1 -// ========================== -// === Arguments Handling === -// ========================== - /** * Check if the given list of application startup arguments denotes an attempt to open a file. * @@ -40,7 +27,7 @@ const NOT_FOUND = -1 * executable name and any electron dev mode arguments. * @returns The path to the file to open, or `null` if no file was specified. */ -export function argsDenoteFileOpenAttempt(clientArgs: readonly string[]): string | null { +export function getFileToOpen(clientArgs: readonly string[]): string | null { const arg = clientArgs[0] let result: string | null = null // If the application is invoked with exactly one argument and this argument is a file, we @@ -57,11 +44,11 @@ export function argsDenoteFileOpenAttempt(clientArgs: readonly string[]): string return result } -/** Get the arguments, excluding the initial program name and any electron dev mode arguments. */ -export const CLIENT_ARGUMENTS = getClientArguments() - -/** Decide what are client arguments, @see {@link CLIENT_ARGUMENTS}. */ -function getClientArguments(args = process.argv): readonly string[] { +/** Parse client arguments. */ +export function parseClientArguments( + args: readonly string[], + electronIsDev: boolean, +): readonly string[] { if (electronIsDev) { // Client arguments are separated from the electron dev mode arguments by a '--' argument. const separator = '--' @@ -84,10 +71,6 @@ function getClientArguments(args = process.argv): readonly string[] { } } -// ========================= -// === File Associations === -// ========================= - /** Check if the given path looks like a file that we can open. */ export function isFileOpenable(path: string): boolean { const extension = pathModule.extname(path).toLowerCase() @@ -98,7 +81,7 @@ export function isFileOpenable(path: string): boolean { } /** Callback called when a file is opened via the `open-file` event. */ -export function onFileOpened(event: electron.Event, path: string): string | null { +export function onFileOpened(event: Event, path: string): string | null { console.log(`Received 'open-file' event for path '${path}'.`) if (isFileOpenable(path)) { console.log(`The file '${path}' is openable.`) @@ -116,7 +99,10 @@ export function onFileOpened(event: electron.Event, path: string): string | null * if this IDE instance should load the project. See {@link onFileOpened} for more details. * @param setProjectToOpen - A function that will be called with the path of the project to open. */ -export function setOpenFileEventHandler(setProjectToOpen: (path: string) => void) { +export function setOpenFileEventHandler( + setProjectToOpen: (path: string) => void, + electron: Electron, +) { electron.app.on('open-file', (_event, path) => { console.log(`Opening file '${path}'.`) setProjectToOpen(path) @@ -154,7 +140,7 @@ export function setOpenFileEventHandler(setProjectToOpen: (path: string) => void * @returns The ID of the project to open. * @throws {Error} if the project from the file cannot be opened or imported. */ -export function handleOpenFile(openedFile: string): project.ProjectInfo { +export function handleOpenFile(openedFile: string, electron: Electron): project.ProjectInfo { try { const title = openedFile .split(pathModule.sep) diff --git a/app/electron-client/src/index.ts b/app/electron-client/src/index.ts index a86e82b1e339..6ba0a7a49bc5 100644 --- a/app/electron-client/src/index.ts +++ b/app/electron-client/src/index.ts @@ -9,35 +9,43 @@ import './cjs-shim' // must be imported first -import * as fsSync from 'node:fs' -import * as fs from 'node:fs/promises' -import * as os from 'node:os' -import * as pathModule from 'node:path' +import { createRemoteBackend } from '@/backend' +import { appPath, assetsPath } from '@/paths' +import type { BrowserWindowConstructorOptions, WebPreferences } from 'electron' +import { DEEP_LINK_SCHEME, PRODUCT_NAME } from 'enso-common/src/constants' +import { + buildWebAppURLSearchParamsFromArgs, + defaultOptions, + type Options, +} from 'enso-common/src/options' +import { EnsoPath } from 'enso-common/src/services/Backend' +import { Path } from 'enso-common/src/utilities/file' +import { access, constants, readFile, writeFile } from 'node:fs/promises' +import { platform } from 'node:os' +import { join as joinPath } from 'node:path' import process from 'node:process' - -import * as electron from 'electron' -import * as common from 'enso-common' -import { buildWebAppURLSearchParamsFromArgs, type Options } from 'enso-common/src/options' - -import * as authentication from '@/authentication' -import * as configParser from '@/configParser' -import * as contentConfig from '@/contentConfig' -import * as debug from '@/debug' -import * as fileAssociations from '@/fileAssociations' -import * as ipc from '@/ipc' -import * as log from '@/log' -import * as naming from '@/naming' -import * as paths from '@/paths' -import * as projectService from '@/projectService' -import * as security from '@/security' -import * as server from '@/server' -import * as urlAssociations from '@/urlAssociations' -import * as projectManagement from 'project-manager-shim' -import { toElectronFileFilter, type FileFilter } from './fileBrowser' - -import * as download from 'electron-dl' -import type { DownloadUrlOptions } from 'enso-common/src/download' -import { filterByRole, inheritMenuItem, makeMenuItem, replaceMenuItems } from './menuItems' +import { downloadSamples, runHybridProjectByUrl, runLocalProjectByPath } from 'project-manager-shim' +import { initAuthentication } from './authentication.js' +import { parseArgs } from './configParser.js' +import { VERSION } from './contentConfig.js' +import { printInfo, VERSION_INFO } from './debug.js' +import { initIpc, registerShortcuts, setChromeOptions } from './electron.js' +import { + getFileToOpen, + handleOpenFile, + parseClientArguments, + setOpenFileEventHandler, +} from './fileAssociations.js' +import { Channel } from './ipc.js' +import { setupLogger } from './log.js' +import { filterByRole, inheritMenuItem, makeMenuItem, replaceMenuItems } from './menuItems.js' +import { capitalizeFirstLetter } from './naming.js' +import { handleProjectProtocol, setupProjectService, version } from './projectService.js' +import { enableAll } from './security.js' +import { Config, Server } from './server.js' +import { getUrlToOpen, handleOpenUrl, registerAssociations } from './urlAssociations.js' + +type Electron = typeof import('electron') const DEFAULT_WINDOW_WIDTH = 1380 const DEFAULT_WINDOW_HEIGHT = 900 @@ -51,146 +59,174 @@ function pathToURL(path: string): URL { } } -// =========== -// === App === -// =========== +interface ParsedArguments { + readonly args: Options + readonly fileToOpen: string | null + readonly urlToOpen: URL | null +} /** * The Electron application. It is responsible for starting all the required services, and * displaying and managing the app window. */ class App { - window: electron.BrowserWindow | null = null - server: server.Server | null = null - startupProject: URL | undefined - isQuitting = false + window: import('electron').BrowserWindow | null = null + server: Server | null = null + webOptions: Options = defaultOptions() + isQuitting: boolean = false + electron: Electron | undefined = undefined + electronIsDev: boolean = false + parsedArguments: ParsedArguments | null = null + + async init() { + this.electronIsDev = + process.argv.includes('--headless') ? + process.argv[0] ? + /electron(?:\.exe)?$/i.test(process.argv[0]) + : false + : (await import('electron-is-dev')).default + const clientArguments = parseClientArguments(process.argv, this.electronIsDev) + this.parsedArguments = this.processArguments(clientArguments) + this.electron = this.parsedArguments.args.headless ? undefined : await import('electron') + } - /** Initialize and run the Electron application. */ - async run() { - log.setupLogger() - urlAssociations.registerAssociations() + exit(code: number) { + if (this.electron) { + this.electron.app.exit(code) + } else { + process.exit(code) + } + } + + quit() { + if (this.electron) { + this.electron.app.quit() + } else { + process.exit(0) + } + } + + showErrorBox(title: string, content: string) { + if (this.electron) { + this.electron.dialog.showErrorBox(title, content) + } else { + console.error(`${title}\n\n${content}`) + } + } + + runElectronApp(args: Options, fileToOpen: string | null, urlToOpen: URL | null) { + if (!this.electron) { + throw new Error('Electron is not available, cannot run Electron app.') + } + const electron = this.electron + registerAssociations(this.electron, this.electronIsDev) // Register file associations for macOS. - fileAssociations.setOpenFileEventHandler((path) => { + setOpenFileEventHandler((path) => { if (electron.app.isReady()) { - const project = fileAssociations.handleOpenFile(path) - this.window?.webContents.send(ipc.Channel.openProject, project) + const project = handleOpenFile(path, electron) + this.window?.webContents.send(Channel.openProject, project) } else { this.setProjectToOpenOnStartup(pathToURL(path)) } + }, this.electron) + const isOriginalInstance = this.electron.app.requestSingleInstanceLock({ + fileToOpen, + urlToOpen, }) - const { args, fileToOpen, urlToOpen } = this.processArguments() - if (args.version) { - await this.printVersion() - electron.app.quit() - } else if (args.debug.info) { - await electron.app.whenReady().then(async () => { - await debug.printInfo() - electron.app.quit() - }) - } else { - const isOriginalInstance = electron.app.requestSingleInstanceLock({ - fileToOpen, - urlToOpen, - }) - if (isOriginalInstance) { - this.handleItemOpening(fileToOpen, urlToOpen) - this.setChromeOptions() - security.enableAll() + if (isOriginalInstance) { + this.handleItemOpening(fileToOpen, urlToOpen) + setChromeOptions(electron) + enableAll() - this.onStart().catch((err) => { - console.error(err) - }) + this.onStart(this.electron).catch((err) => { + console.error(err) + }) - electron.app.on('before-quit', () => { - this.isQuitting = true - }) + this.electron.app.on('before-quit', () => { + this.isQuitting = true + }) - electron.app.on('second-instance', (_event, argv) => { - console.error(`Got data from 'second-instance' event: '${argv.toString()}'.`) + this.electron.app.on('second-instance', (_event, argv) => { + console.error(`Got data from 'second-instance' event: '${argv.toString()}'.`) - const isWin = os.platform() === 'win32' + const isWin = platform() === 'win32' - if (isWin) { - const ensoLinkInArgs = argv.find((arg) => arg.startsWith(common.DEEP_LINK_SCHEME)) + if (isWin) { + const ensoLinkInArgs = argv.find((arg) => arg.startsWith(DEEP_LINK_SCHEME)) - if (ensoLinkInArgs != null) { - electron.app.emit('open-url', new CustomEvent('open-url'), ensoLinkInArgs) - } + if (ensoLinkInArgs != null) { + electron.app.emit('open-url', new CustomEvent('open-url'), ensoLinkInArgs) } + } - // The second instances will close themselves, but our window likely is not in the - // foreground - the focus went to the "second instance" of the application. - if (this.window) { - if (this.window.isMinimized()) { - this.window.restore() - } - this.window.focus() - } else { - console.error('No window found after receiving URL from second instance.') + // The second instances will close themselves, but our window likely is not in the + // foreground - the focus went to the "second instance" of the application. + if (this.window) { + if (this.window.isMinimized()) { + this.window.restore() } - }) - electron.app.whenReady().then( - async () => { - console.log('Electron application is ready.') - - electron.protocol.handle('enso', (request) => - projectService.handleProjectProtocol( - decodeURIComponent(request.url.replace('enso://', '')), - ), - ) - - await this.main(args) - }, - (error) => { - console.error('Failed to initialize Electron.', error) - }, - ) - this.registerShortcuts() - } else { - console.log('Another instance of the application is already running, exiting.') - electron.app.quit() - } + this.window.focus() + } else { + console.error('No window found after receiving URL from second instance.') + } + }) + electron.app.whenReady().then( + async () => { + console.log('Electron application is ready.') + + electron.protocol.handle('enso', (request) => + handleProjectProtocol(decodeURIComponent(request.url.replace('enso://', ''))), + ) + + await this.main(args) + }, + (error) => { + console.error('Failed to initialize Electron.', error) + }, + ) + registerShortcuts(this.electron) + } else { + console.log('Another instance of the application is already running, exiting.') + this.quit() } } /** Background tasks scheduled on the application startup. */ - async onStart() { - const userData = electron.app.getPath('userData') - const versionInfoPath = pathModule.join(userData, 'version_info.json') - const versionInfoPathExists = await fs - .access(versionInfoPath, fs.constants.F_OK) - .then(() => true) - .catch(() => false) - - if (versionInfoPathExists) { - const versionInfoText = await fs.readFile(versionInfoPath, 'utf8') - const versionInfoJson = JSON.parse(versionInfoText) - - if (debug.VERSION_INFO.version === versionInfoJson.version && !contentConfig.VERSION.isDev()) - return - } + async onStart(electron: Electron | undefined) { + const writeVersionInfoPromise = (async () => { + if (!electron) return + const userData = electron.app.getPath('userData') + const versionInfoPath = joinPath(userData, 'version_info.json') + const versionInfoPathExists = await access(versionInfoPath, constants.F_OK) + .then(() => true) + .catch(() => false) + + if (versionInfoPathExists) { + const versionInfoText = await readFile(versionInfoPath, 'utf8') + const versionInfoJson = JSON.parse(versionInfoText) + + if (VERSION_INFO.version === versionInfoJson.version && !VERSION.isDev()) return + } - const writeVersionInfoPromise = fs.writeFile( - versionInfoPath, - JSON.stringify(debug.VERSION_INFO), - 'utf8', - ) - const downloadSamplesPromise = projectManagement.downloadSamples() + return writeFile(versionInfoPath, JSON.stringify(VERSION_INFO), 'utf8') + })() + + const downloadSamplesPromise = downloadSamples() return Promise.allSettled([writeVersionInfoPromise, downloadSamplesPromise]) } /** Process the command line arguments. */ - processArguments(args = fileAssociations.CLIENT_ARGUMENTS) { + processArguments(args: readonly string[]): ParsedArguments { // We parse only "client arguments", so we don't have to worry about the Electron-Dev vs // Electron-Proper distinction. - const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt(args) - const urlToOpen = urlAssociations.argsDenoteUrlOpenAttempt(args) + const fileToOpen = getFileToOpen(args) + const urlToOpen = getUrlToOpen(args) // If we are opening a file (i.e. we were spawned with just a path of the file to open as // the argument) or URL, it means that effectively we don't have any non-standard arguments. - // We just need to let caller know that we are opening a file. + // We just need to let the caller know that we are opening a file. const argsToParse = fileToOpen != null || urlToOpen != null ? [] : args - return { args: configParser.parseArgs(argsToParse), fileToOpen, urlToOpen } + return { args: parseArgs(argsToParse), fileToOpen, urlToOpen } } /** @@ -199,19 +235,23 @@ class App { * This method should be called before the application is ready, as it only * modifies the startup options. If the application is already initialized, * an error will be logged, and the method will have no effect. - * @param projectUrl - The `file://` url of project to be opened on startup. + * @param projectUrl - The `file://` url of the project to be opened on startup. */ setProjectToOpenOnStartup(projectUrl: URL) { + if (!this.electron) { + console.error( + `Cannot set the project to open on startup to '${projectUrl}', as the application is running in headless mode.`, + ) + return + } // Make sure that we are not initialized yet, as this method should be called before the // application is ready. - if (!electron.app.isReady()) { - console.log(`Setting the project to open on startup to '${projectUrl.toString()}'.`) - this.startupProject = projectUrl + if (!this.electron.app.isReady()) { + console.log(`Setting the project to open on startup to '${projectUrl}'.`) + this.webOptions.startup.project = projectUrl.toString() } else { console.error( - "Cannot set the project to open on startup to '" + - projectUrl.toString() + - "', as the application is already initialized.", + `Cannot set the project to open on startup to '${projectUrl}', as the application is already initialized.`, ) } } @@ -230,7 +270,7 @@ class App { } if (urlToOpen != null) { - urlAssociations.handleOpenUrl(urlToOpen) + handleOpenUrl(urlToOpen) } } catch { // If we failed to open the file, we should enter the usual welcome screen. @@ -238,293 +278,152 @@ class App { } } - /** - * Set Chrome options based on the app configuration. For comprehensive list of available - * Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. - */ - setChromeOptions() { - // Needed to accept localhost self-signed cert - electron.app.commandLine.appendSwitch('ignore-certificate-errors') - // Enable native CPU-mappable GPU memory buffer support on Linux. - electron.app.commandLine.appendSwitch('enable-native-gpu-memory-buffers') - // Override the list of blocked GPU hardware, allowing for GPU acceleration on system configurations - // that do not inherently support it. It should be noted that some hardware configurations may have - // driver issues that could result in rendering discrepancies. Despite this, the utilization of GPU - // acceleration has the potential to significantly enhance the performance of the application in our - // specific use cases. This behavior can be observed in the following example: - // https://groups.google.com/a/chromium.org/g/chromium-dev/c/09NnO6jYT6o. - electron.app.commandLine.appendSwitch('ignore-gpu-blocklist') - } - /** Main app entry point. */ async main(args: Options) { // We catch all errors here. Otherwise, it might be possible that the app will run partially // and enter a "zombie mode", where user is not aware of the app still running. try { + if (this.electron) { + // Note that we want to do all the actions synchronously, so when the window + // appears, it serves the website immediately. + await this.startContentServerIfEnabled(args) + } console.log('Starting the application with args', args) - // Note that we want to do all the actions synchronously, so when the window - // appears, it serves the website immediately. - await this.startContentServerIfEnabled(args) await this.createWindowIfEnabled(args) - this.initIpc() + initIpc(this.window) await this.loadWindowContent(args) - /** - * The non-null assertion on the following line is safe because the window - * initialization is guarded by the `createWindowIfEnabled` method. The window is - * not yet created at this point, but it will be created by the time the - * authentication module uses the lambda providing the window. - */ - authentication.initAuthentication(() => this.window!) + if (this.electron) { + /** + * The non-null assertion on the following line is safe because the window + * initialization is guarded by the `createWindowIfEnabled` method. The window is + * not yet created at this point, but it will be created by the time the + * authentication module uses the lambda providing the window. + */ + initAuthentication(this.electron, () => this.window!) + } } catch (err) { console.error('Failed to initialize the application, shutting down. Error: ', err) - electron.app.quit() - } - } - - /** Run the provided function if the provided option was enabled. Log a message otherwise. */ - async runIfEnabled(option: boolean, fn: () => Promise | void) { - if (option) { - await fn() + this.quit() } } /** Setup the project service. */ - private createProjectService(args: Options) { + createProjectService(args: Options) { + if (!this.electron) { + throw new Error('Cannot create the project service in headless mode.') + } const backendVerboseOpts = args.debug.verbose ? ['--log-level', 'trace'] : [] const backendProfileTime = ['--profiling-time', String(args.debug.profileTime)] const backendProfileOpts = args.debug.profile ? ['--profiling-path', 'profiling.npss', ...backendProfileTime] : [] const backendJvmOpts = args.useJvm ? ['--jvm'] : [] const backendOpts = [...backendVerboseOpts, ...backendProfileOpts, ...backendJvmOpts] - - return projectService.setupProjectService(backendOpts) + return setupProjectService(backendOpts, this.electron, this.electronIsDev) } /** Start the content server, which will serve the application content (HTML) to the window. */ async startContentServerIfEnabled(args: Options) { - await this.runIfEnabled(args.useServer, async () => { - console.log('Starting the content server.') - const serverCfg = new server.Config({ - dir: paths.ASSETS_PATH, - port: args.server.port, - }) - const projectService = this.createProjectService(args) - this.server = await server.Server.create(serverCfg, projectService) - console.log('Content server started.') + if (!args.useServer) return + console.log('Starting the content server.') + const serverCfg = new Config({ + dir: assetsPath(this.electron), + port: args.server.port, }) + const projectService = this.createProjectService(args) + this.server = await Server.create(serverCfg, projectService) + console.log('Content server started.') } /** Create the Electron window and display it on the screen. */ async createWindowIfEnabled(args: Options) { - await this.runIfEnabled(args.displayWindow, () => { - console.log('Creating the window.') - const webPreferences: electron.WebPreferences = { - preload: pathModule.join(paths.APP_PATH, 'preload.mjs'), - sandbox: true, - spellcheck: false, - ...(process.env.ENSO_TEST ? { partition: 'test' } : {}), - } - const windowPreferences: electron.BrowserWindowConstructorOptions = { - webPreferences, - width: DEFAULT_WINDOW_WIDTH, - height: DEFAULT_WINDOW_HEIGHT, - frame: true, - titleBarStyle: 'default', - ...(process.env.DEV_DARK_BACKGROUND ? { backgroundColor: '#36312c' } : {}), - } - const window = new electron.BrowserWindow(windowPreferences) - - const oldMenu = electron.Menu.getApplicationMenu() - if (oldMenu != null) { - const newMenu = replaceMenuItems(oldMenu.items, [ - { - filter: [filterByRole('help')], - replacement: (item) => - inheritMenuItem(item, undefined, [ - makeMenuItem(window, `About ${common.PRODUCT_NAME}`, 'about'), - ]), - }, - { - filter: [filterByRole('fileMenu'), filterByRole('close')], - replacement: () => makeMenuItem(window, 'Close Tab', 'closeTab', 'CmdOrCtrl+W'), - }, - { - filter: [filterByRole('appMenu'), filterByRole('about')], - replacement: () => undefined, - }, - { - filter: [filterByRole('appMenu'), filterByRole('hide')], - replacement: (item) => inheritMenuItem(item, `Hide ${common.PRODUCT_NAME}`), - }, - { - filter: [filterByRole('appMenu'), filterByRole('quit')], - replacement: (item) => inheritMenuItem(item, `Quit ${common.PRODUCT_NAME}`), - }, - ]) - electron.Menu.setApplicationMenu(newMenu) - } - window.setMenuBarVisibility(false) - - if (args.debug.devTools) { - window.webContents.openDevTools() - } - - const allowedPermissions = ['clipboard-read', 'clipboard-sanitized-write'] - window.webContents.session.setPermissionRequestHandler( - (_webContents, permission, callback) => { - if (allowedPermissions.includes(permission)) { - callback(true) - } else { - console.error(`Denied permission check '${permission}'.`) - callback(false) - } + if (!args.displayWindow) return + if (!this.electron) { + console.error('Running in headless mode, window will not be created.') + return + } + console.log('Creating the window.') + const webPreferences: WebPreferences = { + preload: joinPath(appPath(this.electron), 'preload.mjs'), + sandbox: true, + spellcheck: false, + ...(process.env.ENSO_TEST ? { partition: 'test' } : {}), + } + const windowPreferences: BrowserWindowConstructorOptions = { + webPreferences, + width: DEFAULT_WINDOW_WIDTH, + height: DEFAULT_WINDOW_HEIGHT, + frame: true, + titleBarStyle: 'default', + ...(process.env.DEV_DARK_BACKGROUND ? { backgroundColor: '#36312c' } : {}), + } + const window = new this.electron.BrowserWindow(windowPreferences) + const oldMenu = this.electron.Menu.getApplicationMenu() + if (oldMenu != null) { + const newMenu = replaceMenuItems(oldMenu.items, [ + { + filter: [filterByRole('help')], + replacement: (item) => + inheritMenuItem(item, undefined, [ + makeMenuItem(window, `About ${PRODUCT_NAME}`, 'about'), + ]), }, - ) - - // Quit application on window close on all platforms except Mac (it is default behavior on Mac). - const closeToQuit = process.platform !== 'darwin' - - window.on('close', (event) => { - if (!this.isQuitting && !closeToQuit) { - event.preventDefault() - window.hide() - } - }) - - electron.app.on('activate', () => { - if (!closeToQuit) { - window.show() - } - }) + { + filter: [filterByRole('fileMenu'), filterByRole('close')], + replacement: () => makeMenuItem(window, 'Close Tab', 'closeTab', 'CmdOrCtrl+W'), + }, + { + filter: [filterByRole('appMenu'), filterByRole('about')], + replacement: () => undefined, + }, + { + filter: [filterByRole('appMenu'), filterByRole('hide')], + replacement: (item) => inheritMenuItem(item, `Hide ${PRODUCT_NAME}`), + }, + { + filter: [filterByRole('appMenu'), filterByRole('quit')], + replacement: (item) => inheritMenuItem(item, `Quit ${PRODUCT_NAME}`), + }, + ]) + this.electron.Menu.setApplicationMenu(newMenu) + } + window.setMenuBarVisibility(false) - window.webContents.on('render-process-gone', (_event, details) => { - console.error('Error, the render process crashed.', details) - }) + if (args.debug.devTools) { + window.webContents.openDevTools() + } - this.window = window - console.log('Window created.') + const allowedPermissions = ['clipboard-read', 'clipboard-sanitized-write'] + window.webContents.session.setPermissionRequestHandler((_webContents, permission, callback) => { + if (allowedPermissions.includes(permission)) { + callback(true) + } else { + console.error(`Denied permission check '${permission}'.`) + callback(false) + } }) - } - /** - * Initialize Inter-Process Communication between the Electron application and the served - * website. - */ - initIpc() { - electron.ipcMain.on(ipc.Channel.error, (_event, data) => { - console.error(...data) - }) - electron.ipcMain.on(ipc.Channel.warn, (_event, data) => { - console.warn(...data) - }) - electron.ipcMain.on(ipc.Channel.log, (_event, data) => { - console.log(...data) - }) - electron.ipcMain.on(ipc.Channel.info, (_event, data) => { - console.info(...data) - }) - electron.ipcMain.on( - ipc.Channel.importProjectFromPath, - (event, path: string, directory: string | null, title: string) => { - const directoryParams = directory == null ? [] : [directory] - const info = projectManagement.importProjectFromPath(path, ...directoryParams, title) - event.reply(ipc.Channel.importProjectFromPath, path, info) - }, - ) - electron.ipcMain.handle( - ipc.Channel.downloadURL, - async (_event, options: DownloadUrlOptions) => { - const { url, path, name, shouldUnpackProject, showFileDialog } = options - // This should never happen, but we'll check for it anyway. - if (!this.window) { - throw new Error('Window is not available.') - } + // Quit application on window close on all platforms except Mac (it is default behavior on Mac). + const closeToQuit = process.platform !== 'darwin' - await download.download(this.window, url, { - ...(path != null ? { directory: path } : {}), - ...(name != null ? { filename: name } : {}), - saveAs: showFileDialog != null ? showFileDialog : path == null, - onCompleted: (file) => { - const path = file.path - const filenameRaw = pathModule.basename(path) - - try { - if ( - projectManagement.isProjectBundle(path) || - projectManagement.isProjectRoot(path) - ) { - if (!shouldUnpackProject) { - return - } - // in case we're importing a project bundle, we need to remove the extension - // from the filename - const filename = filenameRaw.replace(pathModule.extname(filenameRaw), '') - const directory = pathModule.dirname(path) - - projectManagement.importProjectFromPath(path, directory, filename) - fsSync.unlinkSync(path) - } - } catch (error) { - console.error('Error downloading URL', error) - } - }, - }) - - return - }, - ) - electron.ipcMain.on(ipc.Channel.showItemInFolder, (_event, fullPath: string) => { - electron.shell.showItemInFolder(fullPath) + window.on('close', (event) => { + if (!app.isQuitting && !closeToQuit) { + event.preventDefault() + window.hide() + } }) - electron.ipcMain.handle( - ipc.Channel.openFileBrowser, - async ( - _event, - kind: 'default' | 'directory' | 'file' | 'filePath', - defaultPath?: string, - filters?: FileFilter[], - ) => { - console.log('Request for opening browser for ', kind, defaultPath, JSON.stringify(filters)) - let retval = null - if (kind === 'filePath') { - // "Accept", as the file won't be created immediately. - const { canceled, filePath } = await electron.dialog.showSaveDialog({ - buttonLabel: 'Accept', - filters: filters?.map(toElectronFileFilter) ?? [], - ...(defaultPath != null ? { defaultPath } : {}), - }) - if (!canceled) { - retval = [filePath] - } - } else { - /** Helper for `showOpenDialog`, which has weird types by default. */ - type Properties = ('openDirectory' | 'openFile')[] - const properties: Properties = - kind === 'file' ? ['openFile'] - : kind === 'directory' ? ['openDirectory'] - : process.platform === 'darwin' ? ['openFile', 'openDirectory'] - : ['openFile'] - const { canceled, filePaths } = await electron.dialog.showOpenDialog({ - properties, - filters: filters?.map(toElectronFileFilter) ?? [], - ...(defaultPath != null ? { defaultPath } : {}), - }) - if (!canceled) { - retval = filePaths - } - } - return retval - }, - ) - // Handling navigation events from renderer process - electron.ipcMain.on(ipc.Channel.goBack, () => { - this.window?.webContents.navigationHistory.goBack() + this.electron.app.on('activate', () => { + if (!closeToQuit) { + window.show() + } }) - electron.ipcMain.on(ipc.Channel.goForward, () => { - this.window?.webContents.navigationHistory.goForward() + window.webContents.on('render-process-gone', (_event, details) => { + console.error('Error, the render process crashed.', details) }) + + app.window = window + console.log('Window created.') } /** @@ -538,94 +437,114 @@ class App { /** Redirect the web view to `localhost:` to see the served website. */ async loadWindowContent(args: Options) { - if (this.window != null) { - if (this.startupProject != null) { - args.startup.project = this.startupProject.toString() - } - const searchParams = buildWebAppURLSearchParamsFromArgs(args) - const address = new URL('https://localhost') - address.port = this.serverPort(args).toString() - address.search = searchParams.toString() - console.log(`Loading the window address '${address.toString()}'.`) - if (process.env.ELECTRON_DEV_MODE === 'true') { - // Vite takes a while to be `import`ed, so the first load almost always fails. - // Reload every second until Vite is ready - // (i.e. when `index.html` has a non-empty body). - const window = this.window - const onLoad = () => { - void window.webContents.mainFrame - // Get the HTML contents of `document.body`. - .executeJavaScript('document.body.innerHTML') - .then((html) => { - // If `document.body` is empty, then `index.html` failed to load. - if (html === '') { - console.warn('Loading failed, reloading...') - window.webContents.once('did-finish-load', onLoad) - setTimeout(() => { - void window.loadURL(address.toString()) - }, 1_000) - } - }) - } - // Wait for page to load before checking content, because of course the content is - // empty if the page isn't loaded. - window.webContents.once('did-finish-load', onLoad) + if (!this.window) return + const searchParams = buildWebAppURLSearchParamsFromArgs({ + ...this.webOptions, + ...args, + }) + const address = new URL('https://localhost') + address.port = this.serverPort(args).toString() + address.search = searchParams.toString() + console.log(`Loading the window address '${address.toString()}'.`) + if (process.env.ELECTRON_DEV_MODE === 'true') { + // Vite takes a while to be `import`ed, so the first load almost always fails. + // Reload every second until Vite is ready + // (i.e. when `index.html` has a non-empty body). + const window = this.window + const onLoad = () => { + void window.webContents.mainFrame + // Get the HTML contents of `document.body`. + .executeJavaScript('document.body.innerHTML') + .then((html) => { + // If `document.body` is empty, then `index.html` failed to load. + if (html === '') { + console.warn('Loading failed, reloading...') + window.webContents.once('did-finish-load', onLoad) + setTimeout(() => { + void window.loadURL(address.toString()) + }, 1_000) + } + }) } - await this.window.loadURL(address.toString()) + // Wait for page to load before checking content, because of course the content is + // empty if the page isn't loaded. + window.webContents.once('did-finish-load', onLoad) } + await this.window.loadURL(address.toString()) } /** Print the version of the frontend and the backend. */ async printVersion(): Promise { const indent = ' ' let maxNameLen = 0 - for (const name in debug.VERSION_INFO) { + for (const name in VERSION_INFO) { maxNameLen = Math.max(maxNameLen, name.length) } process.stdout.write('Frontend:\n') - for (const [name, value] of Object.entries(debug.VERSION_INFO)) { - const label = naming.capitalizeFirstLetter(name) + for (const [name, value] of Object.entries(VERSION_INFO)) { + const label = capitalizeFirstLetter(name) const spacing = ' '.repeat(maxNameLen - name.length) process.stdout.write(`${indent}${label}:${spacing} ${value}\n`) } process.stdout.write('\n') process.stdout.write('Backend:\n') - const backend = await projectService.version() + const backend = await version(this.electron, this.electronIsDev) const lines = backend.split(/\r?\n/).filter((line) => line.length > 0) for (const line of lines) { process.stdout.write(`${indent}${line}\n`) } } - registerShortcuts() { - electron.app.on('web-contents-created', (_webContentsCreatedEvent, webContents) => { - webContents.on('before-input-event', (_beforeInputEvent, input) => { - const { code, alt, control, shift, meta, type } = input - if (type === 'keyDown') { - const focusedWindow = electron.BrowserWindow.getFocusedWindow() - if (focusedWindow) { - if (control && alt && shift && !meta && code === 'KeyI') { - focusedWindow.webContents.toggleDevTools() - } - if (control && alt && shift && !meta && code === 'KeyR') { - focusedWindow.reload() - } - } - } - }) + /** Initialize and run the Electron application. */ + async run() { + if (!this.parsedArguments) { + throw new Error('Parsed arguments are not available, call `init()` first.') + } + const { args, fileToOpen, urlToOpen } = this.parsedArguments + process.on('uncaughtException', (err, origin) => { + console.error(`Uncaught exception: ${err.toString()}\nException origin: ${origin}`) + this.showErrorBox(PRODUCT_NAME, err.stack ?? err.toString()) + this.exit(1) }) + setupLogger(this.electron) + if (args.version) { + await this.printVersion() + return this.quit() + } else if (args.debug.info) { + await this.electron?.app.whenReady() + await printInfo() + return this.quit() + } else if (this.electron) { + this.runElectronApp(args, fileToOpen, urlToOpen) + } else if (args.headless) { + const projectToOpen = args.startup.project + if (projectToOpen.startsWith(`${DEEP_LINK_SCHEME}:`)) { + try { + await runHybridProjectByUrl( + EnsoPath(projectToOpen.toString()), + await createRemoteBackend(), + ) + this.exit(0) + } catch (error) { + console.error(`Error starting hybrid project '${projectToOpen}':`, error) + return this.exit(1) + } + } else if (projectToOpen) { + try { + await runLocalProjectByPath(Path(projectToOpen)) + this.exit(0) + } catch (error) { + console.error(`Error starting local project '${projectToOpen}':`, error) + return this.exit(1) + } + } else { + console.error('Usage: `--headless --startup.project `') + return this.exit(1) + } + } } } -// =================== -// === App startup === -// =================== - -process.on('uncaughtException', (err, origin) => { - console.error(`Uncaught exception: ${err.toString()}\nException origin: ${origin}`) - electron.dialog.showErrorBox(common.PRODUCT_NAME, err.stack ?? err.toString()) - electron.app.exit(1) -}) - -const APP = new App() -void APP.run() +const app = new App() +await app.init() +await app.run() diff --git a/app/electron-client/src/log.ts b/app/electron-client/src/log.ts index 28cfb06ec740..5a1dca245436 100644 --- a/app/electron-client/src/log.ts +++ b/app/electron-client/src/log.ts @@ -7,18 +7,13 @@ * This is the primary entry point, though its building blocks are also exported, * like {@link Logger}. */ - +import * as contentConfig from '@/contentConfig' +import type { Electron } from '@/electron' +import * as paths from '@/paths' import * as fsSync from 'node:fs' import * as pathModule from 'node:path' import * as util from 'node:util' -import * as contentConfig from '@/contentConfig' -import * as paths from '@/paths' - -// ================ -// === Log File === -// ================ - const consoleLog = console.log const consoleError = console.error @@ -36,10 +31,10 @@ type LogLevel = 'log' | 'info' | 'error' | 'warn' | 'debug' * * The path of the log file is {@link generateUniqueLogFileName automatically generated}. * - * The log file is created in the {@link paths.LOGS_DIRECTORY logs directory} + * The log file is created in the {@link paths.logsPath logs directory} */ -export function setupLogger() { - const dirname = paths.LOGS_DIRECTORY +export function setupLogger(electron: Electron | undefined): void { + const dirname = paths.logsPath(electron) const filename = generateUniqueLogFileName() const logFilePath = pathModule.join(dirname, filename) const consumer = new Logger(logFilePath) diff --git a/app/electron-client/src/paths.ts b/app/electron-client/src/paths.ts index ccc0b8700971..3d21fab050fe 100644 --- a/app/electron-client/src/paths.ts +++ b/app/electron-client/src/paths.ts @@ -1,14 +1,9 @@ /** @file File system paths used by the application. */ - +import type { Electron } from '@/electron' +import { PRODUCT_NAME } from 'enso-common/src/constants' +import { homedir } from 'node:os' import * as path from 'node:path' -import * as electron from 'electron' -import electronIsDev from 'electron-is-dev' - -// ============= -// === Paths === -// ============= - /** * The root of the application bundle. * @@ -16,7 +11,21 @@ import electronIsDev from 'electron-is-dev' * - for packaged application `…/resources/app.asar`; * - for development `…` (just the directory with `index.js`). */ -export const APP_PATH = electron.app.getAppPath() +export function appPath(electron: Electron | undefined) { + if (electron) { + return electron.app.getAppPath() + } else { + const executableName = process.argv[0] + if (!executableName) { + throw new Error('Cannot determine application path: process.argv[0] is undefined.') + } + if (/electron/i.test(path.basename(executableName))) { + // In development, assets are located in the CWD. + return process.cwd() + } + return path.resolve(__dirname, 'resources', 'app.asar') + } +} /** * The path of the directory in which the log files of IDE are stored. @@ -24,19 +33,48 @@ export const APP_PATH = electron.app.getAppPath() * This is based on the Electron `logs` directory, see {@link electron.app.getPath}. * By default, it is `~/Library/Logs/enso` on Mac, and inside `userData` directory on Windows and Linux. */ -export const LOGS_DIRECTORY = electron.app.getPath('logs') +export function logsPath(electron: Electron | undefined) { + if (electron) { + return electron.app.getPath('logs') + } else { + const home = homedir() + switch (process.platform) { + case 'darwin': { + return path.join(home, 'Library', 'Logs', PRODUCT_NAME) + } + case 'win32': { + return path.join(home, 'AppData', 'Roaming', PRODUCT_NAME, 'logs') + } + case 'linux': + default: { + return path.join(home, '.config', PRODUCT_NAME, 'logs') + } + } + } +} /** The application assets, all files bundled with it. */ -export const ASSETS_PATH = path.join(APP_PATH, 'assets') +export function assetsPath(electron: Electron | undefined) { + return path.join(appPath(electron), 'assets') +} /** * Path to the `resources` folder. * * Contains other app resources and backend assets. */ -export const RESOURCES_PATH = electronIsDev ? APP_PATH : path.join(APP_PATH, '..') +export function resourcesPath(electron: Electron | undefined, electronIsDev: boolean): string { + return electronIsDev ? appPath(electron) : path.join(appPath(electron), '..') +} /** Relative path of Enso Project package metadata relative to the project root. */ export const PACKAGE_METADATA_RELATIVE = 'package.yaml' /** Relative path of Enso Project PM metadata relative to the project root. */ export const PROJECT_METADATA_RELATIVE = path.join('.enso', 'project.json') + +/** Path to the credentials file stored in the user's home directory. */ +export const CREDENTIALS_PATH = path.join( + homedir(), + `.${PRODUCT_NAME.toLowerCase()}`, + 'credentials', +) diff --git a/app/electron-client/src/projectService.ts b/app/electron-client/src/projectService.ts index 96c8b2d602a5..67e63b5b2db9 100644 --- a/app/electron-client/src/projectService.ts +++ b/app/electron-client/src/projectService.ts @@ -3,37 +3,41 @@ import { net } from 'electron' import * as url from 'node:url' +import type { Electron } from '@/electron' import * as paths from '@/paths' import { getProjectRoot } from 'project-manager-shim' import { ProjectService } from 'project-manager-shim/projectService' -// ======================= -// === Project Service === -// ======================= - let projectService: ProjectService | null = null -let extraArgs: string[] = [] +let extraArgs: readonly string[] = [] /** Get the project service. */ -function getProjectService(): ProjectService { +function getProjectService(electron: Electron | undefined, electronIsDev: boolean): ProjectService { if (!projectService) { - projectService = ProjectService.default(paths.RESOURCES_PATH, extraArgs) + projectService = ProjectService.default(paths.resourcesPath(electron, electronIsDev), extraArgs) } return projectService } /** Setup the project service.*/ -export function setupProjectService(args: string[]) { +export function setupProjectService( + args: readonly string[], + electron: Electron, + electronIsDev: boolean, +): ProjectService { extraArgs = args if (!projectService) { - projectService = ProjectService.default(paths.RESOURCES_PATH, args) + projectService = ProjectService.default(paths.resourcesPath(electron, electronIsDev), args) } return projectService } /** Get the Project Manager version. */ -export async function version() { - return await getProjectService().version() +export async function version( + electron: Electron | undefined, + electronIsDev: boolean, +): Promise { + return await getProjectService(electron, electronIsDev).version() } /** @@ -42,11 +46,10 @@ export async function version() { * The protocol is used to fetch project assets from the backend. * If a given path is not inside a project, the request is rejected with a 403 error. */ -export async function handleProjectProtocol(absolutePath: string) { +export async function handleProjectProtocol(absolutePath: string): Promise { if (getProjectRoot(absolutePath) == null) { console.error(`The given path is not inside a project: ${absolutePath}.`) return new Response(null, { status: 403 }) } - return net.fetch(url.pathToFileURL(absolutePath).toString()) } diff --git a/app/electron-client/src/server.ts b/app/electron-client/src/server.ts index 7d0b7faa81aa..55a93d2c4f70 100644 --- a/app/electron-client/src/server.ts +++ b/app/electron-client/src/server.ts @@ -2,7 +2,6 @@ import * as mkcert from 'mkcert' import * as http from 'node:http' -import * as https from 'node:https' import * as path from 'node:path' import * as stream from 'node:stream' import * as streamConsumers from 'node:stream/consumers' @@ -12,7 +11,7 @@ import * as mime from 'mime-types' import * as portfinder from 'portfinder' import type * as vite from 'vite' -import { COOP_COEP_CORP_HEADERS } from 'enso-common' +import { COOP_COEP_CORP_HEADERS } from 'enso-common/src/constants' import * as projectManagement from 'project-manager-shim' import type { Watcher } from 'project-manager-shim/fs' import { @@ -25,8 +24,10 @@ import { import * as ydocServer from 'ydoc-server' import { tarFsPack, unzipEntries, zipWriteStream } from '@/archive' +import { downloadCloudProject } from '@/assetManagement' import { BUNDLED_PROJECT_SUFFIX } from '@/fileAssociations' import * as paths from '@/paths' +import * as electron from 'electron' import { app } from 'electron' import { type AnyAsset, @@ -59,7 +60,7 @@ import { GET_FILE_DETAILS_REGEX, } from 'enso-common/src/services/Backend/remoteBackendPaths' import { createReadStream, createWriteStream, statSync } from 'node:fs' -import { access, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { access, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { finished } from 'node:stream/promises' import { pathToFileURL } from 'node:url' @@ -169,10 +170,6 @@ async function findPort(port: number): Promise { return await portfinder.getPortPromise({ port, startPort: port, stopPort: port + 4 }) } -// ============== -// === Server === -// ============== - /** * A simple server implementation. * @@ -190,7 +187,7 @@ export class Server { public config: Config, projectService: ProjectService, ) { - this.projectsRootDirectory = projectManagement.getProjectsDirectory().replace(/\\/g, '/') + this.projectsRootDirectory = projectManagement.getProjectsDirectory() this.projectService = projectService } @@ -352,7 +349,9 @@ export class Server { // in contrast to all assets loaded by the window, which are loaded from `assets/` via // this server. const resourceFile = - resource === '/preload.mjs.map' ? paths.APP_PATH + resource : this.config.dir + resource + resource === '/preload.mjs.map' ? + paths.appPath(electron) + resource + : this.config.dir + resource for (const [header, value] of COOP_COEP_CORP_HEADERS) { response.setHeader(header, value) } @@ -437,21 +436,6 @@ export class Server { this.httpOkText(response, this.apiGetDownloadDirectoryPath()) } - /** Download a project from the cloud. */ - async apiCloudDownloadProject(downloadUrl: string, projectId: ProjectId) { - const response = await new Promise((resolve) => - https.get(downloadUrl, resolve), - ) - const projectsDirectory = projectManagement.getProjectsDirectory() - const parentDirectory = path.join(projectsDirectory, `cloud-${projectId}`) - const projectRootDirectory = path.join(parentDirectory, 'project_root') - - await rm(parentDirectory, { recursive: true, force: true, maxRetries: 3 }) - await mkdir(projectRootDirectory, { recursive: true }) - await projectManagement.unpackBundle(response, projectRootDirectory) - return { projectRootDirectory, parentDirectory } - } - /** Response handler for "download project from cloud" endpoint. */ async httpCloudDownloadProject( _request: http.IncomingMessage, @@ -468,7 +452,7 @@ export class Server { this.httpOkJson<{ readonly projectRootDirectory: string readonly parentDirectory: string - }>(response, await this.apiCloudDownloadProject(downloadUrl, ProjectId(projectId))) + }>(response, await downloadCloudProject(downloadUrl, ProjectId(projectId))) } catch (error) { console.error(error) const projectsDirectory = projectManagement.getProjectsDirectory() diff --git a/app/electron-client/src/urlAssociations.ts b/app/electron-client/src/urlAssociations.ts index 0f906c5243b5..62297d2c11be 100644 --- a/app/electron-client/src/urlAssociations.ts +++ b/app/electron-client/src/urlAssociations.ts @@ -1,13 +1,7 @@ /** @file URL associations for the IDE. */ +import * as common from 'enso-common/src/constants' -import * as electron from 'electron' -import electronIsDev from 'electron-is-dev' - -import * as common from 'enso-common' - -// ============================ -// === Protocol Association === -// ============================ +type Electron = typeof import('electron') /** * Register the application as a handler for our [deep link scheme]{@link common.DEEP_LINK_SCHEME}. @@ -18,7 +12,7 @@ import * as common from 'enso-common' * It is also no-op on macOS, as the OS handles the URL opening by passing the `open-url` event to * the application, thanks to the information baked in our application by `electron-builder`. */ -export function registerAssociations() { +export function registerAssociations(electron: Electron, electronIsDev: boolean) { if (!electron.app.isDefaultProtocolClient(common.DEEP_LINK_SCHEME)) { if (process.platform === 'darwin') { // Registration is handled automatically there thanks to electron-builder. @@ -28,10 +22,6 @@ export function registerAssociations() { } } -// ==================== -// === URL handling === -// ==================== - /** * Check if the given list of application startup arguments denotes an attempt to open a URL. * @@ -42,7 +32,7 @@ export function registerAssociations() { * executable name and any electron dev mode arguments. * @returns The URL to open, or `null` if no file was specified. */ -export function argsDenoteUrlOpenAttempt(clientArgs: readonly string[]): URL | null { +export function getUrlToOpen(clientArgs: readonly string[]): URL | null { const arg = clientArgs[0] let result: URL | null = null // Check if the first argument parses as a URL using our deep link scheme. @@ -80,7 +70,7 @@ export function handleOpenUrl(openedUrl: URL) { * new instance of the application is started and the URL is passed as a command line argument. * @param callback - The callback to call when the application is requested to open a URL. */ -export function registerUrlCallback(callback: (url: URL) => void) { +export function registerUrlCallback(electron: Electron, callback: (url: URL) => void) { if (initialUrl != null) { callback(initialUrl) } diff --git a/app/electron-client/tests/electronTest.ts b/app/electron-client/tests/electronTest.ts index 50cc9044431c..19a419cf66e9 100644 --- a/app/electron-client/tests/electronTest.ts +++ b/app/electron-client/tests/electronTest.ts @@ -1,6 +1,4 @@ /** @file Commonly used functions for electron tests */ -/* eslint-disable no-empty-pattern */ - import { TEXTS } from 'enso-common/src/text' import fs from 'node:fs/promises' import os from 'node:os' @@ -17,22 +15,34 @@ import { const LOADING_TIMEOUT = 10000 const TEXT = TEXTS.english const TEST_USER_FILE = path.join(import.meta.dirname, '../playwright/.auth/user.json') +const POSSIBLE_ELECTRON_PATHS = [ + '../../../dist/ide/linux-unpacked/enso', + '../../../dist/ide/win-unpacked/Enso.exe', + '../../../dist/ide/mac/Enso.app/Contents/MacOS/Enso', + '../../../dist/ide/mac-arm64/Enso.app/Contents/MacOS/Enso', +] + +export const credentials: { readonly user: string; readonly password: string } = await fs + .readFile(TEST_USER_FILE, { encoding: 'utf-8' }) + .then( + (contents) => JSON.parse(contents), + (error) => { + throw new Error(`Cannot read Test User credentials from '${TEST_USER_FILE}'.`, { + cause: error, + }) + }, + ) + .catch((error) => { + throw new Error(`Cannot parse Test User credentials from '${TEST_USER_FILE}'.`, { + cause: error, + }) + }) -const credentials = JSON.parse( - await fs.readFile(TEST_USER_FILE, { encoding: 'utf-8' }).catch((err) => { - throw Error('Cannot read Test User credentials.', { cause: err }) - }), -) - -const electronExecutablePath = await (async () => { - const POSSIBLE_EXEC_PATHS = [ - '../../../dist/ide/linux-unpacked/enso', - '../../../dist/ide/win-unpacked/Enso.exe', - '../../../dist/ide/mac/Enso.app/Contents/MacOS/Enso', - '../../../dist/ide/mac-arm64/Enso.app/Contents/MacOS/Enso', - ].map((p) => path.resolve(import.meta.dirname, p)) +export const electronExecutablePath = await (async () => { try { - const promises = POSSIBLE_EXEC_PATHS.map((p) => fs.access(p, fs.constants.X_OK).then(() => p)) + const promises = POSSIBLE_ELECTRON_PATHS.map((p) => path.resolve(import.meta.dirname, p)).map( + (p) => fs.access(p, fs.constants.X_OK).then(() => p), + ) return await Promise.any(promises) } catch { throw Error('Cannot find Enso package') @@ -50,6 +60,7 @@ export const test = base.extend<{ app: ElectronApplication page: Page }>({ + // eslint-disable-next-line no-empty-pattern testRunId: async function ({}, use, testInfo) { await use(`${testInfo.titlePath.join('-')}-${Date.now()}`) }, @@ -58,15 +69,17 @@ export const test = base.extend<{ await use(projectsDir) }, - /** - * Setup for all tests: Create an electron-based app instance. - */ + /** Setup for all tests: Create an electron-based app instance. */ app: async function ({ projectsDir, testRunId }, use) { const args = process.env.ENSO_TEST_APP_ARGS?.split(',') ?? [] const app = await _electron.launch({ executablePath: electronExecutablePath, args, - env: { ...process.env, ENSO_TEST: 'true', ENSO_TEST_PROJECTS_DIR: projectsDir }, + env: { + ...process.env, + ENSO_TEST: 'true', + ENSO_TEST_PROJECTS_DIR: projectsDir.replace(/\\/g, '/'), + }, }) // Set the password as global var before turning on tracing. // This way it will be not disclosed to anyone downloading traces of failed tests. @@ -86,8 +99,8 @@ export const test = base.extend<{ }) /** - * Login as test user. This function asserts that page is the login page, and uses - * credentials from playwright/.auth/user.json file. + * Login as test user - assert that page is the login page, and use credentials from + * `playwright/.auth/user.json`. */ export async function loginAsTestUser(page: Page) { // Login screen @@ -116,9 +129,7 @@ export async function loginAsTestUser(page: Page) { await page.getByRole('button', { name: TEXT.accept }).click() } -/** - * The funcion creates a new Enso project - */ +/** Create a new Enso project */ export async function createNewProject(page: Page) { await page.getByRole('button', { name: 'New Project' }).click() await expect(page.locator('.GraphNode')).toHaveCount(1, { timeout: 60000 }) @@ -127,9 +138,7 @@ export async function createNewProject(page: Page) { await expect(tableViz).toContainText('Welcome To Enso!') } -/** - * If welcome project is to be opened, this function takes you back to your dashboard - */ +/** If welcome project is to be opened, navigate back to the dashboard. */ export async function closeWelcome(page: Page) { const welcomeProjectTab = page.getByRole('tab', { name: 'Getting Started with Enso Analytics' }) const loadingIndicator = welcomeProjectTab.locator('.LoadingSpinner') diff --git a/app/electron-client/tests/headless/writeFile.test.ts b/app/electron-client/tests/headless/writeFile.test.ts new file mode 100644 index 000000000000..65d583070b7c --- /dev/null +++ b/app/electron-client/tests/headless/writeFile.test.ts @@ -0,0 +1,178 @@ +import { EnsoPath, FileId } from 'enso-common/src/services/Backend' +import { RemoteBackend } from 'enso-common/src/services/RemoteBackend' +import { spawn } from 'node:child_process' +import process from 'node:process' +import { arrayBuffer } from 'node:stream/consumers' +import { expect, test } from 'vitest' +import { tarGzWriteStream } from '../../src/archive' +import { createRemoteBackend } from '../../src/backend' +import { electronExecutablePath } from '../electronTest' + +const remoteBackend = await createRemoteBackend() + +function runAppExecutable(args: readonly string[]): Promise<{ + readonly stdout: string + readonly stderr: string + readonly code: number +}> { + // `spawnSync` is fine, use async here in case blocking will be slower in the future. + const appProcess = spawn(electronExecutablePath, args, { + env: { ...process.env, NODE_ENV: 'development' }, + }) + return new Promise<{ + readonly stdout: string + readonly stderr: string + readonly code: number + }>((resolve, reject) => { + appProcess.on('error', reject) + let stdout = '' + let stderr = '' + appProcess.stdout.on('data', (data) => { + stdout += data.toString() + }) + appProcess.stderr.on('data', (data) => { + stderr += data.toString() + }) + appProcess.on('exit', (code) => { + resolve({ stdout, stderr, code: code ?? 0 }) + }) + }) +} + +async function uploadFile(remoteBackend: RemoteBackend, file: File): Promise { + const { presignedUrls, sourcePath, uploadId } = await remoteBackend.uploadFileStart( + { fileId: null, fileName: file.name, parentDirectoryId: null }, + file, + ) + const parts = await Promise.all( + presignedUrls.map((url, index) => + remoteBackend.uploadFileChunk(url, file, index).then((result) => result.part), + ), + ) + await remoteBackend.uploadFileEnd({ + sourcePath, + uploadId, + parts, + fileName: file.name, + assetId: null, + parentDirectoryId: null, + }) +} + +async function createProject(mainEnso: string, projectName: string) { + const fileName = `${projectName}.enso-project` + const fileStream = tarGzWriteStream() + const fileBlob = arrayBuffer(fileStream.stream) + await fileStream.addFile( + Buffer.from(`name: ${projectName} +namespace: local +version: 0.0.1 +edition: 2025.2.1 +prefer-local-libraries: 'true'`), + { name: 'package.yaml' }, + ) + await fileStream.addFolder({ name: 'src' }) + await fileStream.addFile(Buffer.from(mainEnso), { name: 'src/Main.enso' }) + fileStream.finalize() + return new File([await fileBlob], fileName) +} + +async function uploadProject( + remoteBackend: RemoteBackend, + username: string, + projectName: string, + mainEnso: string, + { force = false, log = (_message: unknown) => {} } = {}, +) { + if (force) { + log('Fetching existing project...') + const project = await remoteBackend + .resolveEnsoPath(EnsoPath(`enso://Users/${username}/headless file write.project`)) + .catch(() => null) + if (project) { + log('Existing project found, deleting...') + await remoteBackend.deleteAsset(project.id, { force: true }, project.title) + } else { + log('No existing project found, skipping deletion.') + } + } + log('Uploading new project...') + const projectFile = await createProject(mainEnso, projectName) + await uploadFile(remoteBackend, projectFile) +} + +// FIXME: This test is skipped as it currently has issues. +test.skip('writing to cloud file', async () => { + const valueToWrite = Math.floor(Math.random() * 1_000_000) + console.info('Fetching user info...') + const user = await remoteBackend.usersMe() + if (!user) { + throw new Error('No user logged in') + } + const fileContentToWrite = `\ +from Standard.Base import all +from Standard.Table import all +from Standard.Database import all +from Standard.AWS import all +from Standard.Geo import all +from Standard.Google import all +from Standard.Microsoft import all +from Standard.Snowflake import all +from Standard.Tableau import all +import Standard.Examples +import Standard.Visualization + +main = + file1 = "${valueToWrite}".write '~/headless written file.txt'` + await uploadProject(remoteBackend, user.name, 'headless file write', fileContentToWrite, { + force: true, + log: (message) => console.info(message), + }) + const project = await remoteBackend + .resolveEnsoPath(EnsoPath(`enso://Users/${user.name}/headless file write.project`)) + .catch(() => null) + expect(project?.type === 'project', 'Project was uploaded correctly').toBe(true) + if (project?.type !== 'project') { + throw new Error('Project not found after upload') + } + const actualFileContent = await remoteBackend.getMainFileContent(project.id) + expect(actualFileContent, 'Project content was written correctly').toBe(fileContentToWrite) + console.info('Running app executable...') + const { stdout, stderr, code } = await runAppExecutable([ + '--headless', + '--startup.project', + `enso://Users/${user.name}/headless file write.enso-project`, + ]) + if (code !== 0) { + process.stdout.write(stdout) + process.stderr.write(stderr) + } + expect(code, 'Process should exit with code 0').toBe(0) + console.info('Resolving written file...') + const siblings = await remoteBackend.searchDirectory({ + title: 'headless', + description: null, + extension: null, + query: null, + type: null, + from: null, + labels: null, + pageSize: null, + parentId: null, + sortDirection: null, + sortExpression: null, + }) + console.log(siblings) + const fileAsset = await remoteBackend.resolveEnsoPath( + EnsoPath(`enso://Users/${user.name}/headless written file.txt`), + ) + console.info('Fetching file details...') + const fileInfo = await remoteBackend.getFileDetails( + fileAsset.id as FileId, + 'headless written file.txt', + true, + ) + const fileResponse = await fetch(fileInfo.url!) + const fileContent = await fileResponse.text() + expect(fileContent, 'File content should match the written value').toBe(String(valueToWrite)) +}) diff --git a/app/electron-client/vitest.config.ts b/app/electron-client/vitest.config.ts new file mode 100644 index 000000000000..e93b5e0e7785 --- /dev/null +++ b/app/electron-client/vitest.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + // Increase the default test timeout to accommodate longer-running tests. + testTimeout: 120_000, + include: ['tests/headless/**/*.test.ts'], + silent: 'passed-only', + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +}) diff --git a/app/electron-client/watch.ts b/app/electron-client/watch.ts index 9ac9ac38f868..76d63d187750 100644 --- a/app/electron-client/watch.ts +++ b/app/electron-client/watch.ts @@ -24,6 +24,7 @@ console.log(chalk.cyan('Cleaning IDE dist directory.')) await rm(IDE_DIR_PATH, { recursive: true, force: true }) await mkdir(IDE_DIR_PATH, { recursive: true }) const NODE_MODULES_PATH = path.resolve('./node_modules') +const GUI_CONFIG_PATH = path.resolve('../gui/vite.config.ts') const BUNDLE_READY = (async (): Promise => { console.log(chalk.cyan('Bundling client.')) @@ -94,7 +95,7 @@ console.log(chalk.cyan('Spawning Electron process.')) const electronProcess = spawn('electron', ELECTRON_ARGS, { stdio: 'inherit', shell: true, - env: Object.assign({ NODE_MODULES_PATH }, process.env), + env: Object.assign({ NODE_MODULES_PATH, GUI_CONFIG_PATH }, process.env), }) .on('close', (code) => { if (code === 0) { @@ -109,3 +110,7 @@ const electronProcess = spawn('electron', ELECTRON_ARGS, { electronProcess.kill() exit(1) }) + .on('exit', (code) => { + console.log((code ? chalk.red : chalk.cyan)(`Electron process exited with code ${code}.`)) + exit(code ?? 0) + }) diff --git a/app/gui/.env b/app/gui/.env deleted file mode 100644 index 54c734aa55ad..000000000000 --- a/app/gui/.env +++ /dev/null @@ -1,22 +0,0 @@ -# CAUTION: this file is intentionally NOT ignored. If you want to override those variables, create an `.env.local` file. -ENSO_IDE_AG_GRID_LICENSE_KEY= -ENSO_IDE_API_URL=https://aaaaaaaaaa.execute-api.mars.amazonaws.com -ENSO_IDE_CHAT_URL=wss://chat.example.com -ENSO_IDE_CLOUD_BUILD=false -ENSO_IDE_COGNITO_DOMAIN=somewhere.auth.mars.amazoncognito.com -ENSO_IDE_COGNITO_REGION=mars -ENSO_IDE_COGNITO_USER_POOL_ID=mars_AAAAAAAAA -ENSO_IDE_COGNITO_USER_POOL_WEB_CLIENT_ID=zzzzzzzzzzzzzzzzzzzzzzzzzz -ENSO_IDE_COMMIT_HASH=abcdef123 -ENSO_IDE_ENVIRONMENT=development -ENSO_IDE_GOOGLE_ANALYTICS_TAG= -ENSO_IDE_HOST=https://ensoanalytics.com -ENSO_IDE_MAPBOX_API_TOKEN= -ENSO_IDE_STRIPE_KEY=pk_test_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -ENSO_IDE_AUTH_ENDPOINT=https://aaaaaaaaaa.execute-api.mars.amazonaws.com/path/to/auth/endpoint -ENSO_IDE_VERSION=0.0.0-dev -ENSO_IDE_PROJECT_MANAGER_URL=ws://__HOSTNAME__:30535 -ENSO_IDE_SENTRY_DSN= -# Potential override variables useful only for local development: -# ENSO_IDE_YDOC_SERVER_URL= -# ENSO_IDE_YDOC_LS_DEBUG=false diff --git a/app/gui/.env.testing b/app/gui/.env.testing deleted file mode 100644 index a035546ca7ea..000000000000 --- a/app/gui/.env.testing +++ /dev/null @@ -1,11 +0,0 @@ -ENSO_IDE_API_URL=https://mock -ENSO_IDE_SENTRY_DSN= -ENSO_IDE_STRIPE_KEY= -ENSO_IDE_AUTH_ENDPOINT= -ENSO_IDE_COGNITO_USER_POOL_ID= -ENSO_IDE_COGNITO_USER_POOL_WEB_CLIENT_ID= -ENSO_IDE_COGNITO_DOMAIN= -ENSO_IDE_COGNITO_REGION= -ENSO_IDE_VERSION=0.0.1-testing -ENSO_IDE_COMMIT_HASH=abcdef0 -ENSO_IDE_CLOUD_BUILD=false diff --git a/app/gui/BUILD.bazel b/app/gui/BUILD.bazel index 7641ba3c8534..c9b7b745e00a 100644 --- a/app/gui/BUILD.bazel +++ b/app/gui/BUILD.bazel @@ -67,7 +67,7 @@ npm_package( visibility = ["//visibility:public"], ) -ENV_FILES = glob([".env*"]) +ENV_FILES = ["//app/common:env_files"] # When stamping, `--mode=bazel` will inject placeholders into the code base which will be replaced later by `stamp_files` rule. _vite_args = select({ @@ -87,10 +87,12 @@ _vite_env = select({ "//:stamping": { "NODE_ENV": "production", "NODE_OPTIONS": "--max-old-space-size=6144", + "ENSO_ENV_FILES_ARE_IN_GUI": "true", }, "//conditions:default": { "NODE_ENV": getenv("NODE_ENV"), "NODE_OPTIONS": "--max-old-space-size=6144", + "ENSO_ENV_FILES_ARE_IN_GUI": "true", }, }) diff --git a/app/gui/env.d.ts b/app/gui/env.d.ts index 490fad463024..5003ced7636a 100644 --- a/app/gui/env.d.ts +++ b/app/gui/env.d.ts @@ -4,7 +4,7 @@ * monkeypatching on `window` and generated code. */ /// -import type { $Config } from './src/config' +import type { $Config } from 'enso-common/src/config' import type { ElectronApi } from './src/electronApi' import type { FeatureFlags } from './src/providers/featureFlags' diff --git a/app/gui/integration-test/actions/index.ts b/app/gui/integration-test/actions/index.ts index ed73b3fbcd63..8907674ff144 100644 --- a/app/gui/integration-test/actions/index.ts +++ b/app/gui/integration-test/actions/index.ts @@ -1,9 +1,9 @@ /** @file Various actions, locators, and constants used in end-to-end tests. */ -import { getText as baseGetText, type Replacements, type TextId } from 'enso-common/src/text' +import { getText as originalGetText, type Replacements, type TextId } from 'enso-common/src/text' // Also necessary as a hack to avoid circular import errors. import { TEXT } from './utilities' export * from './utilities' export const getText = (key: TextId, ...replacements: Replacements[TextId]) => { - return baseGetText(TEXT, key, ...replacements) + return originalGetText(TEXT, key, ...replacements) } diff --git a/app/gui/src/appUtils.ts b/app/gui/src/appUtils.ts index aaf297e5210d..a29c3cf1db67 100644 --- a/app/gui/src/appUtils.ts +++ b/app/gui/src/appUtils.ts @@ -48,7 +48,7 @@ export function getUpgradeURL(plan: string): string { /** Return url address of Enso Analytics contact page. */ export function getContactPage(): string { - return 'https://ensoanalytics.com/contact' + return `${$config.ENSO_HOST}/contact` } /** Return the mailto URL for contacting sales. */ diff --git a/app/gui/src/authentication/service.ts b/app/gui/src/authentication/service.ts index ec2bd666a951..f75f464215bf 100644 --- a/app/gui/src/authentication/service.ts +++ b/app/gui/src/authentication/service.ts @@ -5,14 +5,14 @@ */ import type { Logger } from '#/providers/LoggerProvider' import * as appUtils from '$/appUtils' -import * as cognitoModule from '$/authentication/cognito' +import { Cognito } from '$/authentication/cognito' import * as listen from '$/authentication/listen' import { useFeatureFlag } from '$/providers/featureFlags' import { useText } from '$/providers/text' import { parseEnsoDeeplink } from '@/util/url' import * as amplify from '@aws-amplify/auth' -import * as common from 'enso-common' import type * as saveAccessTokenModule from 'enso-common/src/accessToken' +import * as common from 'enso-common/src/constants' import * as detect from 'enso-common/src/utilities/detect' import * as toastify from 'react-toastify' import { useRouter } from 'vue-router' @@ -98,8 +98,8 @@ export interface AuthConfig { /** API for the authentication service. */ export interface AuthService { - /** @see {@link cognitoModule.Cognito}. */ - readonly cognito: cognitoModule.Cognito + /** @see {@link Cognito}. */ + readonly cognito: Cognito /** @see {@link listen.ListenFunction}. */ readonly registerAuthEventListener: listen.ListenFunction } @@ -121,7 +121,7 @@ export function useInitAuthService(): AuthService { enableDeepLinks.value, (url) => void router.push(url), ) - const cognito = new cognitoModule.Cognito(console, enableDeepLinks.value, amplifyConfig) + const cognito = new Cognito(console, enableDeepLinks.value, amplifyConfig) return { cognito, registerAuthEventListener: listen.registerAuthEventListener } } diff --git a/app/gui/src/components/AppContainer/RightPanel.vue b/app/gui/src/components/AppContainer/RightPanel.vue index ff6b6bd613f9..63da3e6cc2db 100644 --- a/app/gui/src/components/AppContainer/RightPanel.vue +++ b/app/gui/src/components/AppContainer/RightPanel.vue @@ -7,6 +7,7 @@ import { } from '$/components/AppContainer/reactTabs' import SelectableTab from '$/components/AppContainer/SelectableTab.vue' import { useRightPanelData, type RightPanelTabId } from '$/providers/rightPanel' +import AssetContentsEditor from '@/components/AssetContentsEditor.vue' import ComponentHelpPanel from '@/components/ComponentHelpPanel.vue' import DescriptionEditor from '@/components/DescriptionEditor.vue' import DocumentationEditor from '@/components/DocumentationEditor' @@ -22,11 +23,13 @@ import { computed, toValue, useTemplateRef } from 'vue' const data = useRightPanelData() -// Not a part of RightPanelTabInfo, because it would create cyclic imports. +// Not a part of RightPanelTabInfo, because it would create cyclic imports. const component = computed(() => { switch (data.displayedTab) { case 'description': return DescriptionEditor + case 'contents': + return AssetContentsEditor case 'settings': return AssetProperties case 'versions': diff --git a/app/gui/src/config.ts b/app/gui/src/config.ts index c3180d7922c0..dc92479467a7 100644 --- a/app/gui/src/config.ts +++ b/app/gui/src/config.ts @@ -1,40 +1,53 @@ /** - * @file This file defines a global environemnt config that can be used throughout the app. + * @file This file defines a global environment config that can be used throughout the app. * It is included directly into index.html and kept as a separate built artifact, so that * we can easily replace its contents in a separate build postprocessing step in `BUILD.bazel`. */ +import { $config, setConfig } from 'enso-common/src/config' +export type { $Config } from 'enso-common/src/config' -/** - * When running dev server, the config variables are grabbed from appropriate .env file. - */ -const $config = { - ENVIRONMENT: import.meta.env.ENSO_IDE_ENVIRONMENT, - ENSO_HOST: import.meta.env.ENSO_IDE_HOST, - API_URL: import.meta.env.ENSO_IDE_API_URL, - SENTRY_DSN: import.meta.env.ENSO_IDE_SENTRY_DSN, - STRIPE_KEY: import.meta.env.ENSO_IDE_STRIPE_KEY, - AUTH_ENDPOINT: import.meta.env.ENSO_IDE_AUTH_ENDPOINT, - COGNITO_USER_POOL_ID: import.meta.env.ENSO_IDE_COGNITO_USER_POOL_ID, - COGNITO_USER_POOL_WEB_CLIENT_ID: import.meta.env.ENSO_IDE_COGNITO_USER_POOL_WEB_CLIENT_ID, - GOOGLE_ANALYTICS_TAG: import.meta.env.ENSO_IDE_GOOGLE_ANALYTICS_TAG, - COGNITO_DOMAIN: import.meta.env.ENSO_IDE_COGNITO_DOMAIN, - COGNITO_REGION: import.meta.env.ENSO_IDE_COGNITO_REGION, - VERSION: import.meta.env.ENSO_IDE_VERSION, - COMMIT_HASH: import.meta.env.ENSO_IDE_COMMIT_HASH, - YDOC_SERVER_URL: import.meta.env.ENSO_IDE_YDOC_SERVER_URL, - CLOUD_BUILD: import.meta.env.ENSO_IDE_CLOUD_BUILD, - AG_GRID_LICENSE_KEY: import.meta.env.ENSO_IDE_AG_GRID_LICENSE_KEY, - GOOGLE_OAUTH_CLIENT_ID: import.meta.env.ENSO_IDE_GOOGLE_OAUTH_CLIENT_ID, - STRAVA_OAUTH_CLIENT_ID: import.meta.env.ENSO_IDE_STRAVA_OAUTH_CLIENT_ID, - MS365_OAUTH_CLIENT_ID: import.meta.env.ENSO_IDE_MS365_OAUTH_CLIENT_ID, - MAPBOX_API_TOKEN: window.api?.mapBoxApiToken() || import.meta.env.ENSO_IDE_MAPBOX_API_TOKEN, -} as const +const processEnv = typeof process !== 'undefined' ? process.env : {} -// Undefined env variables are typed as `any`, but we want them to be `string | undefined`. -export type $Config = { - [K in keyof typeof $config]: unknown extends (typeof $config)[K] ? string | undefined - : (typeof $config)[K] -} +/** When running dev server, the config variables are grabbed from appropriate .env file. */ +setConfig({ + ENVIRONMENT: processEnv.ENSO_IDE_ENVIRONMENT ?? import.meta.env?.ENSO_IDE_ENVIRONMENT, + ENSO_HOST: processEnv.ENSO_IDE_HOST ?? import.meta.env?.ENSO_IDE_HOST, + API_URL: processEnv.ENSO_IDE_API_URL ?? import.meta.env?.ENSO_IDE_API_URL, + SENTRY_DSN: processEnv.ENSO_IDE_SENTRY_DSN ?? import.meta.env?.ENSO_IDE_SENTRY_DSN, + STRIPE_KEY: processEnv.ENSO_IDE_STRIPE_KEY ?? import.meta.env?.ENSO_IDE_STRIPE_KEY, + AUTH_ENDPOINT: processEnv.ENSO_IDE_AUTH_ENDPOINT ?? import.meta.env?.ENSO_IDE_AUTH_ENDPOINT, + COGNITO_USER_POOL_ID: + processEnv.ENSO_IDE_COGNITO_USER_POOL_ID ?? import.meta.env?.ENSO_IDE_COGNITO_USER_POOL_ID, + COGNITO_USER_POOL_WEB_CLIENT_ID: + processEnv.ENSO_IDE_COGNITO_USER_POOL_WEB_CLIENT_ID ?? + import.meta.env?.ENSO_IDE_COGNITO_USER_POOL_WEB_CLIENT_ID, + GOOGLE_ANALYTICS_TAG: + processEnv.ENSO_IDE_GOOGLE_ANALYTICS_TAG ?? import.meta.env?.ENSO_IDE_GOOGLE_ANALYTICS_TAG, + COGNITO_DOMAIN: processEnv.ENSO_IDE_COGNITO_DOMAIN ?? import.meta.env?.ENSO_IDE_COGNITO_DOMAIN, + COGNITO_REGION: processEnv.ENSO_IDE_COGNITO_REGION ?? import.meta.env?.ENSO_IDE_COGNITO_REGION, + VERSION: processEnv.ENSO_IDE_VERSION ?? import.meta.env?.ENSO_IDE_VERSION, + COMMIT_HASH: processEnv.ENSO_IDE_COMMIT_HASH ?? import.meta.env?.ENSO_IDE_COMMIT_HASH, + YDOC_SERVER_URL: processEnv.ENSO_IDE_YDOC_SERVER_URL ?? import.meta.env?.ENSO_IDE_YDOC_SERVER_URL, + CLOUD_BUILD: processEnv.ENSO_IDE_CLOUD_BUILD ?? import.meta.env?.ENSO_IDE_CLOUD_BUILD, + AG_GRID_LICENSE_KEY: + processEnv.ENSO_IDE_AG_GRID_LICENSE_KEY ?? import.meta.env?.ENSO_IDE_AG_GRID_LICENSE_KEY, + GOOGLE_OAUTH_CLIENT_ID: + processEnv.ENSO_IDE_GOOGLE_OAUTH_CLIENT_ID ?? import.meta.env?.ENSO_IDE_GOOGLE_OAUTH_CLIENT_ID, + STRAVA_OAUTH_CLIENT_ID: + processEnv.ENSO_IDE_STRAVA_OAUTH_CLIENT_ID ?? import.meta.env?.ENSO_IDE_STRAVA_OAUTH_CLIENT_ID, + MS365_OAUTH_CLIENT_ID: + processEnv.ENSO_IDE_MS365_OAUTH_CLIENT_ID ?? import.meta.env?.ENSO_IDE_MS365_OAUTH_CLIENT_ID, + MAPBOX_API_TOKEN: + (typeof window === 'object' && + window && + 'api' in window && + typeof window.api === 'object' && + window.api && + 'mapBoxApiToken' in window.api && + typeof window.api.mapBoxApiToken === 'function' && + window.api?.mapBoxApiToken()) || + (processEnv.ENSO_IDE_MAPBOX_API_TOKEN ?? import.meta.env?.ENSO_IDE_MAPBOX_API_TOKEN), +}) Object.defineProperty(window, '$config', { writable: false, diff --git a/app/gui/src/dashboard/hooks/backendUploadFilesHooks.tsx b/app/gui/src/dashboard/hooks/backendUploadFilesHooks.tsx index 96955f8ae8f9..6f8b95f45050 100644 --- a/app/gui/src/dashboard/hooks/backendUploadFilesHooks.tsx +++ b/app/gui/src/dashboard/hooks/backendUploadFilesHooks.tsx @@ -49,13 +49,11 @@ declare module '$/utils/queryClient' { * upload to Cloud. */ function useUploadLocally(backend: Backend) { - const localUploadFileStart = useMutationCallback( - backendMutationOptions(backend, 'uploadFileStart'), - ) + const uploadFileStart = useMutationCallback(backendMutationOptions(backend, 'uploadFileStart')) const uploadFileEnd = useMutationCallback(backendMutationOptions(backend, 'uploadFileEnd')) return async (file: File, params: UploadFileRequestParams) => { - const { uploadId, sourcePath } = await localUploadFileStart([params, file]) + const { uploadId, sourcePath } = await uploadFileStart([params, file]) return uploadFileEnd([ { uploadId, diff --git a/app/gui/src/dashboard/layouts/InfoMenu.tsx b/app/gui/src/dashboard/layouts/InfoMenu.tsx index a222dd303e47..f7809ee90449 100644 --- a/app/gui/src/dashboard/layouts/InfoMenu.tsx +++ b/app/gui/src/dashboard/layouts/InfoMenu.tsx @@ -6,7 +6,7 @@ import { Text } from '#/components/Text' import { AboutModal } from '#/modals/AboutModal' import { LOGIN_PATH } from '$/appUtils' import { useAuth, useRouter, useSession, useText } from '$/providers/react' -import { PRODUCT_NAME } from 'enso-common' +import { PRODUCT_NAME } from 'enso-common/src/constants' /** A menu containing info about the app. */ export function InfoMenu() { diff --git a/app/gui/src/dashboard/modals/AgreementsModal.tsx b/app/gui/src/dashboard/modals/AgreementsModal.tsx index d7eaaab58701..2024fc0da4b5 100644 --- a/app/gui/src/dashboard/modals/AgreementsModal.tsx +++ b/app/gui/src/dashboard/modals/AgreementsModal.tsx @@ -60,7 +60,7 @@ export const AgreementsModal = memo(function AgreementsModal(props: AgreementsMo form={form} name="agreedToTos" description={ - } @@ -72,7 +72,7 @@ export const AgreementsModal = memo(function AgreementsModal(props: AgreementsMo form={form} name="agreedToPrivacyPolicy" description={ - } diff --git a/app/gui/src/dashboard/modules/payments/components/PlanSelector/components/Card.tsx b/app/gui/src/dashboard/modules/payments/components/PlanSelector/components/Card.tsx index e6c8c3249101..cbc7518590ac 100644 --- a/app/gui/src/dashboard/modules/payments/components/PlanSelector/components/Card.tsx +++ b/app/gui/src/dashboard/modules/payments/components/PlanSelector/components/Card.tsx @@ -213,7 +213,7 @@ export function Card(props: CardProps) {
} @@ -192,7 +188,7 @@ export default function Registration(props: RegistrationProps) { diff --git a/app/gui/src/dashboard/pages/useExportArchive.ts b/app/gui/src/dashboard/pages/useExportArchive.ts index 4d4e80da7163..5d13b79b0a0f 100644 --- a/app/gui/src/dashboard/pages/useExportArchive.ts +++ b/app/gui/src/dashboard/pages/useExportArchive.ts @@ -5,7 +5,7 @@ import { useDownloadDirectory } from '#/layouts/Drive/useDownloadDirectory' import { useDriveStore } from '#/providers/DriveProvider' import { useMutationCallback } from '#/utilities/tanstackQuery' import { useText } from '$/providers/react' -import { PRODUCT_NAME } from 'enso-common' +import { PRODUCT_NAME } from 'enso-common/src/constants' import type { Backend } from 'enso-common/src/services/Backend' import { Path } from 'enso-common/src/services/Backend' import { toReadableIsoString } from 'enso-common/src/utilities/data/dateTime' diff --git a/app/gui/src/dashboard/utilities/LocalStorage.ts b/app/gui/src/dashboard/utilities/LocalStorage.ts index 56154aa0097a..aaa60215d2ea 100644 --- a/app/gui/src/dashboard/utilities/LocalStorage.ts +++ b/app/gui/src/dashboard/utilities/LocalStorage.ts @@ -1,6 +1,6 @@ /** @file A LocalStorage data manager. */ import { useVueValue } from '$/providers/react/common' -import * as common from 'enso-common' +import * as common from 'enso-common/src/constants' import * as object from 'enso-common/src/utilities/data/object' import { IS_DEV_MODE } from 'enso-common/src/utilities/detect' import { useCallback } from 'react' diff --git a/app/gui/src/dashboard/utilities/github.ts b/app/gui/src/dashboard/utilities/github.ts index 5314064f41e9..237bb8469a92 100644 --- a/app/gui/src/dashboard/utilities/github.ts +++ b/app/gui/src/dashboard/utilities/github.ts @@ -1,5 +1,5 @@ /** @file Utilities getting various metadata about the app. */ -import * as common from 'enso-common' +import * as common from 'enso-common/src/constants' import * as detect from 'enso-common/src/utilities/detect' /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/app/gui/src/project-view/components/AssetContentsEditor.vue b/app/gui/src/project-view/components/AssetContentsEditor.vue new file mode 100644 index 000000000000..f0bde2ad67fb --- /dev/null +++ b/app/gui/src/project-view/components/AssetContentsEditor.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/app/gui/src/project-view/composables/upload.ts b/app/gui/src/project-view/composables/upload.ts new file mode 100644 index 000000000000..62e5b038ab7c --- /dev/null +++ b/app/gui/src/project-view/composables/upload.ts @@ -0,0 +1,41 @@ +import { useUploadsToCloudStore } from '$/providers/upload' +import { backendMutationOptions } from '@/composables/backend' +import type { ToValue } from '@/util/reactivity' +import { useMutation } from '@tanstack/vue-query' +import type { Backend, UploadFileRequestParams } from 'enso-common/src/services/Backend' +import { computed, toValue } from 'vue' + +/** + * Function for uploading files to Local Backend. It requires less hassle than multipart + * upload to Cloud. + */ +function useUploadLocally(backend: ToValue) { + const uploadFileStart = useMutation(backendMutationOptions('uploadFileStart', backend)) + const uploadFileEnd = useMutation(backendMutationOptions('uploadFileEnd', backend)) + + return async (file: File, params: UploadFileRequestParams) => { + const data = await uploadFileStart.mutateAsync([params, file]) + if (!data) { + return + } + const { uploadId, sourcePath } = data + return await uploadFileEnd.mutateAsync([ + { + uploadId, + sourcePath, + parts: [], + assetId: params.fileId, + ...params, + }, + ]) + } +} + +/** A function to upload a file to the backend. */ +export function useUpload(backend: ToValue) { + const uploads = useUploadsToCloudStore() + const uploadLocally = useUploadLocally(backend) + return computed(() => + toValue(backend)?.type === 'local' ? uploadLocally : uploads.uploadFile.bind(uploads), + ) +} diff --git a/app/gui/src/project-view/providers/asyncResources/AsyncResource.ts b/app/gui/src/project-view/providers/asyncResources/AsyncResource.ts index 6ae7baca18b3..86814d91d61e 100644 --- a/app/gui/src/project-view/providers/asyncResources/AsyncResource.ts +++ b/app/gui/src/project-view/providers/asyncResources/AsyncResource.ts @@ -199,7 +199,7 @@ export class AsyncResource { const MAX_CACHED_UNUSED_RESOURCES = 64 /** - * Create a cache that maintains reference counded resources. + * Create a cache that maintains reference counted resources. * * Part of 'asyncResources' store. * @internal diff --git a/app/gui/src/providers/backends.ts b/app/gui/src/providers/backends.ts index 1f560099e7c0..10ef4d20520c 100644 --- a/app/gui/src/providers/backends.ts +++ b/app/gui/src/providers/backends.ts @@ -1,13 +1,13 @@ import { localRootDirectoryStore } from '#/layouts/Drive/persistentState' import { download } from '#/utilities/download' -import { injectGuiConfig, type GuiConfig } from '@/providers/guiConfig' import { proxyRefs, type ToValue } from '@/util/reactivity' import { createGlobalState } from '@vueuse/core' -import { BackendType, Path } from 'enso-common/src/services/Backend' +import { BackendType, DirectoryId, Path } from 'enso-common/src/services/Backend' import { HttpClient } from 'enso-common/src/services/HttpClient' import { LocalBackend } from 'enso-common/src/services/LocalBackend' import { ProjectManager } from 'enso-common/src/services/ProjectManager/ProjectManager' import { RemoteBackend } from 'enso-common/src/services/RemoteBackend' +import { extractIdFromDirectoryId } from 'enso-common/src/services/RemoteBackend/ids' import invariant from 'tiny-invariant' import { computed, inject, ref, toValue, watch, watchEffect } from 'vue' import { useHttpClient } from './httpClient' @@ -16,7 +16,6 @@ import { useText, type GetText } from './text' export type BackendsStore = ReturnType function initializeBackends( httpClient: HttpClient, - config: ToValue, rootDirPath: ToValue, getText: GetText, ) { @@ -42,12 +41,39 @@ function initializeBackends( ) : null, ) - const remoteBackend = new RemoteBackend( + const remoteBackend = new RemoteBackend({ getText, - httpClient, - download, - new URL($config.API_URL ?? '', location.href), - ) + client: httpClient, + downloader: download, + downloadCloudProject: async function downloadCloudProject(this: RemoteBackend, params) { + const queryString = new URLSearchParams(params) + const response = await this.get<{ + readonly projectRootDirectory: string + readonly parentDirectory: string + }>(new URL(`/api/cloud/download-project?${queryString}`, location.href).toString()) + if (!response.ok) { + return await this.throw(response, 'resolveProjectAssetPathBackendError') + } + return await response.json() + }, + getProjectArchive: async function getProjectArchive( + this: RemoteBackend, + directoryId: DirectoryId, + fileName: string, + ): Promise { + const queryString = new URLSearchParams({ + directory: extractIdFromDirectoryId(directoryId), + }).toString() + const response = await this.get( + new URL(`/api/cloud/get-project-archive?${queryString}`, location.href).toString(), + ) + if (!response.ok) { + return await this.throw(response, 'resolveProjectAssetPathBackendError') + } + const responseBody = await response.arrayBuffer() + return new File([responseBody], fileName) + }, + }) watch( () => getText, @@ -79,5 +105,5 @@ function initializeBackends( } export const useBackends = createGlobalState(() => - initializeBackends(useHttpClient(), injectGuiConfig(), inject('rootDirPath'), useText().getText), + initializeBackends(useHttpClient(), inject('rootDirPath'), useText().getText), ) diff --git a/app/gui/src/providers/openedProjects/projectStates.ts b/app/gui/src/providers/openedProjects/projectStates.ts index 757694cd4d2c..757ef3231db7 100644 --- a/app/gui/src/providers/openedProjects/projectStates.ts +++ b/app/gui/src/providers/openedProjects/projectStates.ts @@ -322,13 +322,7 @@ export function useProjectStates() { ) { if (!backends.localBackend) return Err('Cannot open local project: Local Backend missing.') await backends.localBackend - .startWatchingHybridProject( - info.id, - info.runningId, - info.parentId, - backends.remoteBackend.baseUrl, - httpClient.defaultHeaders, - ) + .startWatchingHybridProject(info.id, info.runningId, info.parentId, httpClient.defaultHeaders) .catch((err) => { console.error(`Failed to start watching hybrid project ${info.id}`, err) }) diff --git a/app/gui/src/providers/rightPanel.ts b/app/gui/src/providers/rightPanel.ts index 25d141361187..31efaa334b74 100644 --- a/app/gui/src/providers/rightPanel.ts +++ b/app/gui/src/providers/rightPanel.ts @@ -74,6 +74,14 @@ function useRightPanelTabs( title: 'Description', }, ], + [ + 'contents', + { + icon: 'docs', + enabled: Ok(), + title: 'Contents', + }, + ], [ 'settings', { diff --git a/app/gui/src/providers/text.ts b/app/gui/src/providers/text.ts index 948de71ada12..084c80fbe3aa 100644 --- a/app/gui/src/providers/text.ts +++ b/app/gui/src/providers/text.ts @@ -1,7 +1,17 @@ import { proxyRefs, type MaybeRefOrGetterArray } from '@/util/reactivity' import { createGlobalState } from '@vueuse/core' -import * as text from 'enso-common/src/text' +import { + getDictionary, + LANGUAGE_TO_LOCALE, + getText as originalGetText, + resolveUserLanguage, + type DefaultGetText, + type Language, + type Replacements, + type TextId, +} from 'enso-common/src/text' import { computed, ref, toValue } from 'vue' +export type { DefaultGetText as GetText } from 'enso-common/src/text' export type TextStore = ReturnType @@ -12,39 +22,27 @@ export type TextStore = ReturnType * `injectText` instead. */ function createTextStore() { - const language = ref(text.resolveUserLanguage()) - const locale = computed(() => text.LANGUAGE_TO_LOCALE[language.value]) - const localizedText = computed(() => text.getDictionary(language.value)) + const language = ref(resolveUserLanguage()) + const locale = computed(() => LANGUAGE_TO_LOCALE[language.value]) + const localizedText = computed(() => getDictionary(language.value)) - const getText: GetText = (key, ...replacements) => - text.getText(localizedText.value, key, ...replacements) + const getText: DefaultGetText = (key, ...replacements) => + originalGetText(localizedText.value, key, ...replacements) - function textRef( + function textRef( key: K, - ...replacements: MaybeRefOrGetterArray + ...replacements: MaybeRefOrGetterArray ) { return computed(() => - getText(toValue(key), ...(replacements.map((x) => toValue(x)) as text.Replacements[K])), + getText(toValue(key), ...(replacements.map((x) => toValue(x)) as Replacements[K])), ) } - function setLanguage(lang: text.Language) { + function setLanguage(lang: Language) { language.value = lang } return proxyRefs({ language, locale, getText, textRef, setLanguage }) } -/** - * A function that gets localized text for a given key, with optional replacements. - * @param key - The key of the text to get. - * @param replacements - The replacements to insert into the text. - * If the text contains placeholders like `$0`, `$1`, etc., - * they will be replaced with the corresponding replacement. - */ -export type GetText = ( - key: K, - ...replacements: text.Replacements[K] -) => string - export const useText = createGlobalState(createTextStore) diff --git a/app/gui/vite.config.ts b/app/gui/vite.config.ts index bb9e4468ad99..2b52405d7200 100644 --- a/app/gui/vite.config.ts +++ b/app/gui/vite.config.ts @@ -23,6 +23,7 @@ process.env.LAUNCH_EDITOR ??= 'code' // https://vitejs.dev/config/ export default defineConfig({ + ...(process.env.MODE && { mode: process.env.MODE }), ...(IS_ELECTRON_DEV_MODE ? { root: fileURLToPath(new URL('.', import.meta.url)) } : {}), cacheDir: fileURLToPath(new URL('../../node_modules/.cache/vite', import.meta.url)), plugins: [ @@ -97,6 +98,7 @@ export default defineConfig({ $: fileURLToPath(new URL('./src', import.meta.url)), }, }, + envDir: process.env.ENSO_ENV_FILES_ARE_IN_GUI === 'true' ? '.' : '../common', envPrefix: 'ENSO_IDE_', define: { // Single hardcoded usage of `global` in aws-amplify. diff --git a/app/gui/vitest.config.ts b/app/gui/vitest.config.ts index f0fb01d1f0b2..f79d7238b65b 100644 --- a/app/gui/vitest.config.ts +++ b/app/gui/vitest.config.ts @@ -1,4 +1,5 @@ import { fileURLToPath } from 'node:url' +import { loadEnv } from 'vite' import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' import viteConfig from './vite.config' @@ -6,6 +7,10 @@ const config = mergeConfig( viteConfig, defineConfig({ test: { + // We need to load environment variables locally: https://github.com/vitest-dev/vitest/issues/1148 + // Supposedly, vitest only loads environment variables beginning with VITE_. + // We use ENSO_IDE_ prefix for our variables, so we have to load them manually. + env: loadEnv('testing', '../common', 'ENSO_IDE_'), reporters: process.env.CI ? ['dot', 'github-actions'] : ['default'], environment: 'jsdom', includeSource: ['./src/**/*.{ts,tsx,vue}'], diff --git a/app/project-manager-shim/src/handler/filesystem.ts b/app/project-manager-shim/src/handler/filesystem.ts index 38a9eada56a0..5c9797dd7b99 100644 --- a/app/project-manager-shim/src/handler/filesystem.ts +++ b/app/project-manager-shim/src/handler/filesystem.ts @@ -186,74 +186,10 @@ export async function handleFilesystemCommand( for (const entryName of entryNames) { const entryPath = path.join(currentDirectoryPath, entryName) if (isFileHidden(entryPath)) continue - const stat = await fs.stat(entryPath) - const attributes: Attributes = { - byteSize: stat.size, - creationTime: new Date(stat.ctimeMs).toISOString(), - lastAccessTime: new Date(stat.atimeMs).toISOString(), - lastModifiedTime: new Date(stat.mtimeMs).toISOString(), - } - if (stat.isFile()) { - entries.push({ - type: FileSystemEntryType.FileEntry, - path: entryPath, - attributes, - } satisfies FileEntry) - } else { - if (isRecursive) { - directoryPathQueue.push(entryPath) - } - try { - const packageMetadataPath = path.join(entryPath, 'package.yaml') - const projectMetadataPath = path.join( - entryPath, - projectManagement.PROJECT_METADATA_RELATIVE_PATH, - ) - const packageMetadataContents = await fs.readFile(packageMetadataPath) - const packageMetadataYaml = yaml.parse(packageMetadataContents.toString()) - let projectMetadataJson - try { - const projectMetadataContents = await fs.readFile(projectMetadataPath) - projectMetadataJson = JSON.parse(projectMetadataContents.toString()) - } catch (e) { - if ( - 'name' in packageMetadataYaml && - typeof packageMetadataYaml.name === 'string' - ) { - projectMetadataJson = { - id: crypto.randomUUID(), - kind: 'UserProject', - created: new Date().toISOString(), - lastOpened: null, - } - await fs.mkdir(path.dirname(projectMetadataPath), { recursive: true }) - await fs.writeFile(projectMetadataPath, JSON.stringify(projectMetadataJson)) - } else { - throw e - } - } - const metadata = extractProjectMetadata(packageMetadataYaml, projectMetadataJson) - if (metadata != null) { - // This is a project. - entries.push({ - type: FileSystemEntryType.ProjectEntry, - path: entryPath, - attributes, - metadata, - } satisfies ProjectEntry) - } else { - // This error moves control flow to the - // `catch` clause directly below. - throw new Error('Invalid project metadata.') - } - } catch { - // This is a regular directory, not a project. - entries.push({ - type: FileSystemEntryType.DirectoryEntry, - path: entryPath, - attributes, - } satisfies DirectoryEntry) - } + const entry = await getFileSystemEntry(entryPath) + entries.push(entry) + if (isRecursive && entry.type === FileSystemEntryType.DirectoryEntry) { + directoryPathQueue.push(entryPath) } } } @@ -320,3 +256,70 @@ export async function handleFilesystemCommand( return result } + +/** Get a file system entry for a given path. */ +export async function getFileSystemEntry(entryPath: string): Promise { + const stat = await fs.stat(entryPath) + const attributes: Attributes = { + byteSize: stat.size, + creationTime: new Date(stat.ctimeMs).toISOString(), + lastAccessTime: new Date(stat.atimeMs).toISOString(), + lastModifiedTime: new Date(stat.mtimeMs).toISOString(), + } + if (stat.isFile()) { + return { + type: FileSystemEntryType.FileEntry, + path: entryPath, + attributes, + } + } else { + try { + const packageMetadataPath = path.join(entryPath, 'package.yaml') + const projectMetadataPath = path.join( + entryPath, + projectManagement.PROJECT_METADATA_RELATIVE_PATH, + ) + const packageMetadataContents = await fs.readFile(packageMetadataPath) + const packageMetadataYaml = yaml.parse(packageMetadataContents.toString()) + let projectMetadataJson + try { + const projectMetadataContents = await fs.readFile(projectMetadataPath) + projectMetadataJson = JSON.parse(projectMetadataContents.toString()) + } catch (e) { + if ('name' in packageMetadataYaml && typeof packageMetadataYaml.name === 'string') { + projectMetadataJson = { + id: crypto.randomUUID(), + kind: 'UserProject', + created: new Date().toISOString(), + lastOpened: null, + } + await fs.mkdir(path.dirname(projectMetadataPath), { recursive: true }) + await fs.writeFile(projectMetadataPath, JSON.stringify(projectMetadataJson)) + } else { + throw e + } + } + const metadata = extractProjectMetadata(packageMetadataYaml, projectMetadataJson) + if (metadata != null) { + // This is a project. + return { + type: FileSystemEntryType.ProjectEntry, + path: entryPath, + attributes, + metadata, + } + } else { + // This error moves control flow to the + // `catch` clause directly below. + throw new Error('Invalid project metadata.') + } + } catch { + // This is a regular directory, not a project. + return { + type: FileSystemEntryType.DirectoryEntry, + path: entryPath, + attributes, + } + } + } +} diff --git a/app/project-manager-shim/src/handler/watcher.ts b/app/project-manager-shim/src/handler/watcher.ts index 4e3c29e39158..cf169ce9f21a 100644 --- a/app/project-manager-shim/src/handler/watcher.ts +++ b/app/project-manager-shim/src/handler/watcher.ts @@ -1,8 +1,12 @@ -import { AssetId, DirectoryId, ProjectId, type GetText } from 'enso-common/src/services/Backend' +import { AssetId, DirectoryId, ProjectId } from 'enso-common/src/services/Backend' import { HttpClient } from 'enso-common/src/services/HttpClient' import { RemoteBackend } from 'enso-common/src/services/RemoteBackend' -import { getText, resolveDictionary } from 'enso-common/src/text' +import { extractIdFromDirectoryId } from 'enso-common/src/services/RemoteBackend/ids' +import { getText, resolveDictionary, type DefaultGetText } from 'enso-common/src/text' +import { mkdir, rm } from 'node:fs/promises' import type * as http from 'node:http' +import * as https from 'node:https' +import path from 'node:path' import { watch, type Watcher } from '../fs.js' import * as projectManagement from '../projectManagement.js' import { uploadFile } from '../upload.js' @@ -26,16 +30,39 @@ const PROJECT_WATCHER_CALLBACK_TIMEOUT = 60000 // =========================== /** Create a RemoteBackend instance. */ -function createRemoteBackend(headers: Record, baseUrl: string): RemoteBackend { +function createRemoteBackend(headers: Record): RemoteBackend { const client = new HttpClient(headers) const downloader = () => { // not required for watcher } const dictionary = resolveDictionary() - const backendGetText: GetText = function (key, ...replacements) { + const backendGetText: DefaultGetText = function (key, ...replacements) { return getText(dictionary, key, ...replacements) } - return new RemoteBackend(backendGetText, client, downloader, new URL(baseUrl)) + return new RemoteBackend({ + getText: backendGetText, + client, + downloader, + downloadCloudProject: async (params) => { + const response = await new Promise((resolve) => + https.get(params.downloadUrl, resolve), + ) + const projectsDirectory = projectManagement.getProjectsDirectory() + const parentDirectory = path.join(projectsDirectory, `cloud-${params.projectId}`) + const projectRootDirectory = path.join(parentDirectory, 'project_root') + + await rm(parentDirectory, { recursive: true, force: true, maxRetries: 3 }) + await mkdir(projectRootDirectory, { recursive: true }) + await projectManagement.unpackBundle(response, projectRootDirectory) + return { projectRootDirectory, parentDirectory } + }, + getProjectArchive: async (directoryId, fileName) => { + const parentDir = extractIdFromDirectoryId(directoryId) + const projectDir = path.join(parentDir, 'project_root') + const projectBundle = await projectManagement.createBundle(projectDir) + return new File([projectBundle], fileName) + }, + }) } // ============================ @@ -79,19 +106,12 @@ export async function handleWatcherRequest( .end('Request is missing search parameter `parentDirectoryId`.') break } - const baseUrl = url.searchParams.get('baseUrl') - if (baseUrl == null) { - response - .writeHead(HTTP_STATUS_BAD_REQUEST, headers) - .end('Request is missing search parameter `baseUrl`.') - break - } const parentDirectoryId = parentDirectoryIdString as DirectoryId const assetId = ProjectId(assetIdString) try { const defaultHeaders = await bodyJson>(request) - const backend = createRemoteBackend(defaultHeaders, baseUrl) + const backend = createRemoteBackend(defaultHeaders) const fileName = 'project_root.enso-project' const uploadParams = { fileId: assetId, diff --git a/app/project-manager-shim/src/index.ts b/app/project-manager-shim/src/index.ts index 344ae1844a35..6544e19dfb9e 100644 --- a/app/project-manager-shim/src/index.ts +++ b/app/project-manager-shim/src/index.ts @@ -1,2 +1,3 @@ export * from './projectManagement.js' export { downloadEnsoEngine, findEnsoExecutable } from './projectService/ensoRunner.js' +export * from './runProjects.js' diff --git a/app/project-manager-shim/src/projectManagement.ts b/app/project-manager-shim/src/projectManagement.ts index 8a6b015a6331..706c2741dd6b 100644 --- a/app/project-manager-shim/src/projectManagement.ts +++ b/app/project-manager-shim/src/projectManagement.ts @@ -18,7 +18,7 @@ import type * as stream from 'node:stream' import * as tar from 'tar' -import { PRODUCT_NAME } from 'enso-common' +import { PRODUCT_NAME } from 'enso-common/src/constants' import { Path, UUID } from 'enso-common/src/services/Backend' import { Rfc3339DateTime, toRfc3339 } from 'enso-common/src/utilities/data/dateTime' @@ -26,10 +26,6 @@ import * as desktopEnvironment from './desktopEnvironment.js' const logger = console -// ================= -// === Constants === -// ================= - export const PACKAGE_METADATA_RELATIVE_PATH = 'package.yaml' export const PROJECT_METADATA_RELATIVE_PATH = '.enso/project.json' @@ -37,10 +33,6 @@ const SAMPLES_URL = 'https://github.com/enso-org/project-templates/archive/refs/ const SAMPLES_DIRECTORY_NAME = 'Samples' const BUNDLED_PROJECT_SUFFIX = '.enso-project' -// =================== -// === ProjectInfo === -// =================== - /** Metadata for a newly imported project. */ export interface ProjectInfo { readonly id: UUID @@ -49,10 +41,6 @@ export interface ProjectInfo { readonly parentDirectory: string } -// ====================== -// === Project Import === -// ====================== - /** * Check if the given path is a project bundle. * @param path - The path to check. @@ -420,9 +408,9 @@ export function getProjectsDirectory(): string { const documentsPath = desktopEnvironment.DOCUMENTS if (documentsPath === undefined) { - return pathModule.join(os.homedir(), 'enso', 'projects') + return pathModule.join(os.homedir(), 'enso', 'projects').replace(/\\/g, '/') } else { - return pathModule.join(documentsPath, 'enso-projects') + return pathModule.join(documentsPath, 'enso-projects').replace(/\\/g, '/') } } @@ -434,7 +422,7 @@ function isProjectInstalled(projectRoot: string, directory = getProjectsDirector } /** Create a .tar.gz enso-project bundle. */ -export function createBundle(directory: string): Promise { +export function createBundle(directory: string): Promise> { const readableStream = tar.c( { z: true, diff --git a/app/project-manager-shim/src/projectService/__tests__/ProjectService.test.ts b/app/project-manager-shim/src/projectService/__tests__/ProjectService.test.ts index 63b0fd01f448..470e5fc62a20 100644 --- a/app/project-manager-shim/src/projectService/__tests__/ProjectService.test.ts +++ b/app/project-manager-shim/src/projectService/__tests__/ProjectService.test.ts @@ -1,3 +1,4 @@ +import { PRODUCT_NAME } from 'enso-common/src/constants' import { UUID } from 'enso-common/src/services/Backend' import { newtypeConstructor, type Newtype } from 'enso-common/src/utilities/data/newtype' import * as crypto from 'node:crypto' @@ -32,7 +33,9 @@ describe('ProjectService', () => { projectService = new ProjectService(runner, []) } else { // Fail the test if executable is not available - throw new Error('Enso executable not found. Cannot run tests without Enso runtime.') + throw new Error( + `${PRODUCT_NAME} executable not found. Cannot run tests without Enso runtime.`, + ) } }) @@ -43,7 +46,9 @@ describe('ProjectService', () => { if (!ensoPath) { // Fail the test if executable is not available - throw new Error('Enso executable not found. Cannot run tests without Enso runtime.') + throw new Error( + `${PRODUCT_NAME} executable not found. Cannot run tests without Enso runtime.`, + ) } }) diff --git a/app/project-manager-shim/src/projectService/ensoRunner.ts b/app/project-manager-shim/src/projectService/ensoRunner.ts index f07594be94a4..ba0246223265 100644 --- a/app/project-manager-shim/src/projectService/ensoRunner.ts +++ b/app/project-manager-shim/src/projectService/ensoRunner.ts @@ -10,6 +10,7 @@ import { extract } from 'tar' import { Path } from './types.js' export interface Runner { + runProject(projectPath: Path, extraEnv?: readonly (readonly [string, string])[]): Promise createProject(path: Path, name: string, projectTemplate?: string): Promise openProject( projectPath: Path, @@ -46,12 +47,29 @@ export interface Socket { readonly port: number } -export type ShutdownHookType = 'rename-project-directory' +/** + * Use declaration merging to allow extension of ShutdownHookRegistry in other modules. + * This enables adding new shutdown hook types without modifying the original interface. + * + * For example, in another module, you can add: + * ```ts + * declare module './projectService/ensoRunner.js' { + * interface ShutdownHookRegistry { + * 'my-new-hook-type': true + * } + * } + * ``` + */ +export interface ShutdownHookRegistry { + 'rename-project-directory': true +} + +export type ShutdownHookType = keyof ShutdownHookRegistry interface RunningProject { process: childProcess.ChildProcess sockets: LanguageServerSockets - shutdownHooks: Map Promise> + shutdownHooks: Map void | Promise> } const DEFAULT_JSONRPC_PORT = 30616 @@ -65,25 +83,28 @@ export class EnsoRunner implements Runner { /** Creates a new EnsoRunner with the path to the Enso executable. */ constructor(private ensoPath: Path) {} - /** Creates a new Enso project at the specified path. */ - async createProject(projectPath: Path, name: string, projectTemplate?: string): Promise { - const args: string[] = [] - args.push('--new', projectPath) - args.push('--new-project-name', name) - if (projectTemplate) { - args.push('--new-project-template', projectTemplate) - } + private async runProcess( + args: readonly string[], + spawnCallback: (cmd: string, cmdArgs: readonly string[]) => T, + ) { + const cmd = this.ensoPath.endsWith('.bat') ? 'cmd.exe' : this.ensoPath + const cmdArgs = this.ensoPath.endsWith('.bat') ? ['/c', this.ensoPath, ...args] : args + return spawnCallback(cmd, cmdArgs) + } + private async runCommand( + args: readonly string[], + options?: childProcess.SpawnOptionsWithoutStdio, + ): Promise { + const process = await this.runProcess(args, (cmd, cmdArgs) => + childProcess.spawn(cmd, cmdArgs, options), + ) return new Promise((resolve, reject) => { - const cmd = this.ensoPath.endsWith('.bat') ? 'cmd.exe' : this.ensoPath - const cmdArgs = this.ensoPath.endsWith('.bat') ? ['/c', this.ensoPath, ...args] : args - const process = childProcess.spawn(cmd, cmdArgs) - - let _stdout = '' + let stdout = '' let stderr = '' process.stdout.on('data', (data) => { - _stdout += data.toString() + stdout += data.toString() }) process.stderr.on('data', (data) => { @@ -98,13 +119,53 @@ export class EnsoRunner implements Runner { if (code === 0) { resolve() } else { - reject(new Error(`Enso process exited with code ${code}. stderr: ${stderr}`)) + reject( + new Error( + `Enso process exited with code ${code}.\nstdout: ${stdout}\nstderr: ${stderr}`, + ), + ) + } + }) + }) + } + + /** Run an existing Enso project at the specified path. */ + async runProject( + projectPath: Path, + extraEnv?: readonly (readonly [string, string])[], + ): Promise { + const args = ['--run', projectPath] + const env = { ...process.env, ...(extraEnv ? Object.fromEntries(extraEnv) : {}) } + const cwd = path.dirname(projectPath) + const spawnedProcess = await this.runProcess(args, (cmd, cmdArgs) => + childProcess.spawn(cmd, cmdArgs, { env, cwd, stdio: ['inherit', 'inherit', 'inherit'] }), + ) + return new Promise((resolve, reject) => { + spawnedProcess.on('error', (error) => { + reject(new Error(`Failed to spawn enso process: ${error.message}`)) + }) + spawnedProcess.on('exit', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`Enso process exited with code ${code}.`)) } }) }) } - /** Opens a project and starts its language server. */ + /** Create a new Enso project at the specified path. */ + async createProject(projectPath: Path, name: string, projectTemplate?: string): Promise { + return await this.runCommand([ + '--new', + projectPath, + '--new-project-name', + name, + ...(projectTemplate ? ['--new-project-template', projectTemplate] : []), + ]) + } + + /** Open a project and starts its language server. */ async openProject( projectPath: Path, projectId: string, @@ -126,9 +187,9 @@ export class EnsoRunner implements Runner { await this.loadingProjects.values().next().value } const promise = this.findServerPorts(DEFAULT_JSONRPC_PORT).then( - ([jsonPort, binaryPort, ydocPort]) => { + async ([jsonPort, binaryPort, ydocPort]) => { const rootId = crypto.randomUUID() - const args: string[] = [ + const args: readonly string[] = [ '--server', '--root-id', rootId, @@ -142,33 +203,27 @@ export class EnsoRunner implements Runner { jsonPort.toString(), '--data-port', binaryPort.toString(), + ...(extraArgs ?? []), ] - // Add extra arguments if provided - if (extraArgs) { - args.push(...extraArgs) - } - - const env = { ...process.env } - env['LANGUAGE_SERVER_YDOC_PORT'] = ydocPort.toString() - if (extraEnv) { - for (const [key, value] of extraEnv) { - env[key] = value - } + const env = { + ...process.env, + LANGUAGE_SERVER_YDOC_PORT: ydocPort.toString(), + ...(extraEnv ? Object.fromEntries(extraEnv) : {}), } - return new Promise((resolve, reject) => { - const cmd = this.ensoPath.endsWith('.bat') ? 'cmd.exe' : this.ensoPath - const cmdArgs = this.ensoPath.endsWith('.bat') ? ['/c', this.ensoPath, ...args] : args - const cwd = path.dirname(projectPath) - const serverProcess = childProcess.spawn(cmd, cmdArgs, { + const cwd = path.dirname(projectPath) + const serverProcess = await this.runProcess(args, (cmd, cmdArgs) => + childProcess.spawn(cmd, cmdArgs, { env, detached: false, cwd, stdio: ['pipe', 'inherit', 'inherit'], windowsHide: true, - }) + }), + ) + return new Promise((resolve, reject) => { let resolved = false // Health check function @@ -326,7 +381,7 @@ export class EnsoRunner implements Runner { async registerShutdownHook( projectId: string, hookType: ShutdownHookType, - hook: () => Promise, + hook: () => void | Promise, ): Promise { const runningProject = this.runningProjects.get(projectId) @@ -444,23 +499,67 @@ export class EnsoRunner implements Runner { } } -/** Find the path to the `enso` executable. */ -export function findEnsoExecutable(workDir: string = '.'): Path | undefined { - const checkExecutable = (filePath: string) => { - try { - fs.accessSync(filePath, fs.constants.X_OK) - } catch { - throw new Error(`Enso executable at ${filePath} is not executable`) - } - return Path(filePath) +function checkExecutable(filePath: string) { + try { + fs.accessSync(filePath, fs.constants.X_OK) + } catch { + throw new Error(`Enso executable at ${filePath} is not executable`) } + return Path(filePath) +} - let ensoExecutables: string[] - if (os.platform() === 'win32') { - ensoExecutables = ['enso.exe', 'enso.bat'] - } else { - ensoExecutables = ['enso'] +const ensoExecutables = (() => { + switch (os.platform()) { + case 'win32': { + return ['enso.exe', 'enso.bat'] + } + case 'darwin': + case 'linux': + default: { + return ['enso'] + } + } +})() + +function checkExecutables(...segments: readonly string[]): Path | undefined { + if (!segments.includes('*')) { + for (const ensoExecutable of ensoExecutables) { + const ensoPath = path.join(...segments, ensoExecutable) + try { + fs.accessSync(ensoPath) + return checkExecutable(ensoPath) + } catch { + // File doesn't exist, continue searching + } + } + return + } + const literalSegments: string[] = [] + let i = -1 + for (const segment of segments) { + i += 1 + if (segment === '*') { + const basePath = path.join(...literalSegments) + try { + for (const entry of fs.readdirSync(basePath)) { + const result = checkExecutables(basePath, entry, ...segments.slice(i + 1)) + if (result) { + return result + } + } + } catch { + // Directory doesn't exist, continue searching + } + } else { + literalSegments.push(segment) + } } + return +} + +/** Find the path to the `enso` executable. */ +export function findEnsoExecutable(workDir: string = '.'): Path | undefined { + workDir = path.resolve(workDir) // Check ENSO_ENGINE_PATH environment variable first const envPath = process.env.ENSO_ENGINE_PATH @@ -473,96 +572,28 @@ export function findEnsoExecutable(workDir: string = '.'): Path | undefined { } } - // Check enso/dist/*/bin/enso - const ensoDistPath = path.join(workDir, 'enso', 'dist') - try { - const stat = fs.statSync(ensoDistPath) - if (stat.isDirectory()) { - const distDirs = fs.readdirSync(ensoDistPath) - for (const distDir of distDirs) { - for (const ensoExecutable of ensoExecutables) { - const ensoPath = path.join(ensoDistPath, distDir, 'bin', ensoExecutable) - try { - fs.accessSync(ensoPath) - return checkExecutable(ensoPath) - } catch { - // File doesn't exist, continue searching - } - } - } - } - } catch { - // Directory doesn't exist, continue to next directory - } - - // Check built-distribution/*/enso/dist/*/bin/enso - const builtDistEnsoPath = path.join(workDir, 'built-distribution') - try { - const stat = fs.statSync(builtDistEnsoPath) - if (stat.isDirectory()) { - const topLevelDirs = fs.readdirSync(builtDistEnsoPath) - for (const topDir of topLevelDirs) { - const topPath = path.join(builtDistEnsoPath, topDir) - const topStat = fs.statSync(topPath) - if (topStat.isDirectory()) { - const ensoDistPath = path.join(topPath, 'enso', 'dist') - try { - const distStat = fs.statSync(ensoDistPath) - if (distStat.isDirectory()) { - const distDirs = fs.readdirSync(ensoDistPath) - for (const distDir of distDirs) { - for (const ensoExecutable of ensoExecutables) { - const ensoPath = path.join(ensoDistPath, distDir, 'bin', ensoExecutable) - try { - fs.accessSync(ensoPath) - return checkExecutable(ensoPath) - } catch { - // File doesn't exist, continue searching - } - } - } - } - } catch { - // enso/dist directory doesn't exist, continue searching - } - } - } - } - } catch { - // Directory doesn't exist, continue to next directory - } - - // Check built-distribution/*/*/bin/enso - const builtDistDir = path.join(workDir, 'built-distribution') - try { - const stat = fs.statSync(builtDistDir) - if (stat.isDirectory()) { - const topLevelDirs = fs.readdirSync(builtDistDir) - for (const topDir of topLevelDirs) { - const topPath = path.join(builtDistDir, topDir) - const topStat = fs.statSync(topPath) - if (topStat.isDirectory()) { - const subDirs = fs.readdirSync(topPath) - for (const subDir of subDirs) { - for (const ensoExecutable of ensoExecutables) { - const ensoPath = path.join(topPath, subDir, 'bin', ensoExecutable) - try { - fs.accessSync(ensoPath) - return checkExecutable(ensoPath) - } catch { - // File doesn't exist, continue searching - } - } - } - } - } + const executablePath = process.argv[0] ? path.dirname(process.argv[0]) : undefined + const directories: readonly (readonly string[])[] = [ + // Check executable path + ...(executablePath ? [[executablePath, 'resources', 'enso', 'dist', '*', 'bin']] : []), + // Check executable path for MacOs + ...(executablePath ? [[executablePath, '..', 'Resources', 'enso', 'dist', '*', 'bin']] : []), + // Check enso/dist/*/bin/enso + [workDir, 'enso', 'dist', '*', 'bin'], + // Check built-distribution/*/enso/dist/*/bin/enso + [workDir, 'built-distribution', '*', 'enso', 'dist', '*', 'bin'], + // Check built-distribution/*/*/bin/enso + [workDir, 'built-distribution', '*', '*', 'bin'], + // Macos dist/backend/dist/*/bin nightly + [workDir, 'dist', 'backend', 'dist', '*', 'bin'], + ] + + for (const directory of directories) { + const result = checkExecutables(...directory) + if (result) { + return result } - } catch { - // Directory doesn't exist } - - // No enso executable found - return undefined } /** @@ -696,12 +727,7 @@ export async function downloadEnsoEngine(projectRoot: string): Promise { // Extract the archive if (extensionString === '.tar.gz') { - await pipeline( - fs.createReadStream(archivePath), - extract({ - cwd: extractDir, - }), - ) + await pipeline(fs.createReadStream(archivePath), extract({ cwd: extractDir })) } else { await extractZip(archivePath, { dir: extractDir }) } diff --git a/app/project-manager-shim/src/projectService/index.ts b/app/project-manager-shim/src/projectService/index.ts index b6c207d31e39..f0d412458bed 100644 --- a/app/project-manager-shim/src/projectService/index.ts +++ b/app/project-manager-shim/src/projectService/index.ts @@ -3,6 +3,7 @@ * This module provides project management functionality including creating, deleting, * renaming, opening, closing, and duplicating projects. */ +import { PRODUCT_NAME } from 'enso-common/src/constants' import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime' import * as crypto from 'node:crypto' import { @@ -83,7 +84,7 @@ export class ProjectService { static default(workDir: string = '.', extraArgs: readonly string[] = []): ProjectService { const ensoPath = findEnsoExecutable(workDir) if (!ensoPath) { - throw new Error('Enso executable not found') + throw new Error(`${PRODUCT_NAME} executable not found`) } const runner = new EnsoRunner(ensoPath) @@ -95,9 +96,7 @@ export class ProjectService { return new ProjectService(runner, allExtraArgs) } - /** - * Creates a new user project with the specified configuration. - */ + /** Creates a new user project with the specified configuration. */ async createProject( projectName: string, projectsDirectory: Path, @@ -144,42 +143,65 @@ export class ProjectService { } } - /** Opens a project and starts its language server. */ - async openProject( + private async getProject( projectId: UUID, projectsDirectory: Path, - cloud?: CloudParams, - ): Promise { - this.logger.debug('Opening project', projectId) - - // Get the project repository + update = false, + ): Promise { const repo = this.getProjectRepository(projectsDirectory) - - // Get the project from the repository const project = await repo.findById(projectId) if (!project) { throw new Error(`Project not found: ${projectId}`) } + if (update) { + // Update the lastOpened timestamp + const openTime = toRfc3339(new Date()) + const updatedProject = { ...project, lastOpened: openTime } + await this.getProjectRepository(projectsDirectory).update(updatedProject) + } + return project + } + + private projectEnvVars(cloud?: CloudParams): readonly (readonly [string, string])[] | undefined { + if (!cloud) { + return + } + return [ + ['ENSO_CLOUD_PROJECT_DIRECTORY_PATH', cloud.cloudProjectDirectoryPath], + ['ENSO_CLOUD_PROJECT_ID', cloud.cloudProjectId], + ['ENSO_CLOUD_PROJECT_SESSION_ID', cloud.cloudProjectSessionId], + ] + } + + /** Run an existing Enso project at the specified path. */ + async runProject(projectId: UUID, projectsDirectory: Path, cloud?: CloudParams): Promise { + const project = await this.getProject(projectId, projectsDirectory, true) + this.logger.debug(`Running project '${project.path}'`) + await this.runner.runProject(project.path, this.projectEnvVars(cloud)) + this.logger.debug(`Project '${project.path}' finished running`) + } + + /** Open a project and starts its language server. */ + async openProject( + projectId: UUID, + projectsDirectory: Path, + cloud?: CloudParams, + ): Promise { + this.logger.debug('Opening project', projectId) + + const project = await this.getProject(projectId, projectsDirectory, true) // Update the lastOpened timestamp const openTime = toRfc3339(new Date()) const updatedProject = { ...project, lastOpened: openTime } - await repo.update(updatedProject) - - // Prepare cloud environment variables if provided - const extraEnv: Array<[string, string]> = [] - if (cloud) { - extraEnv.push(['ENSO_CLOUD_PROJECT_DIRECTORY_PATH', cloud.cloudProjectDirectoryPath]) - extraEnv.push(['ENSO_CLOUD_PROJECT_ID', cloud.cloudProjectId]) - extraEnv.push(['ENSO_CLOUD_PROJECT_SESSION_ID', cloud.cloudProjectSessionId]) - } + await this.getProjectRepository(projectsDirectory).update(updatedProject) // Start the language server const sockets = await this.runner.openProject( project.path, projectId, this.extraArgs.length > 0 ? this.extraArgs : undefined, - extraEnv.length > 0 ? extraEnv : undefined, + this.projectEnvVars(cloud), ) // Return the OpenProject response diff --git a/app/project-manager-shim/src/runProjects.ts b/app/project-manager-shim/src/runProjects.ts new file mode 100644 index 000000000000..dc4cd53f62ca --- /dev/null +++ b/app/project-manager-shim/src/runProjects.ts @@ -0,0 +1,97 @@ +import { PRODUCT_NAME } from 'enso-common/src/constants' +import { AssetType, extractTypeAndPath, type ProjectAsset } from 'enso-common/src/services/Backend' +import { EnsoPath } from 'enso-common/src/services/Backend/types' +import { Path, type ProjectEntry, type UUID } from 'enso-common/src/services/ProjectManager/types' +import type { RemoteBackend } from 'enso-common/src/services/RemoteBackend' +import { dirname, resolve } from 'node:path' +import { getFileSystemEntry } from './handler/index.js' +import { EnsoRunner, findEnsoExecutable } from './projectService/ensoRunner.js' +import { ProjectService, type CloudParams } from './projectService/index.js' + +function getWorkDir() { + if (process.env.NODE_ENV === 'development') { + return resolve('../..') + } else { + return '.' + } +} + +function createProjectService(): ProjectService { + const ensoPath = findEnsoExecutable(getWorkDir()) + if (!ensoPath) { + throw new Error(`${PRODUCT_NAME} executable not found`) + } + const runner = new EnsoRunner(ensoPath) + return new ProjectService(runner, []) +} + +/** Run a hybrid project by URL. */ +export async function runHybridProjectByUrl( + path: EnsoPath, + remoteBackend: RemoteBackend, +): Promise { + let project: ProjectEntry | undefined + let asset: ProjectAsset | undefined + try { + const unknownAsset = await remoteBackend.resolveEnsoPath(EnsoPath(decodeURIComponent(path))) + if (unknownAsset.type !== AssetType.project) { + throw new Error(`The path '${path}' does not point to a project.`) + } + asset = unknownAsset + const cloudProjectSessionId = await remoteBackend.setHybridOpenInProgress(asset.id, asset.title) + const localProject = await remoteBackend.downloadProject(asset.id) + let parentPath: Path | undefined + for (const projectId of [localProject.parentId, localProject.projectRootId]) { + const projectPath = extractTypeAndPath(projectId).path + parentPath = Path(dirname(projectPath)) + const entry = await getFileSystemEntry(projectPath) + if (entry.type === 'ProjectEntry') { + project = entry as ProjectEntry + break + } + } + + if (!project || !parentPath) { + throw new Error('Downloaded cloud project does not exist in Local Backend.') + } + const cloudProjectDirectoryPath = Path(asset.ensoPath.slice(0, asset.ensoPath.lastIndexOf('/'))) + await remoteBackend.setHybridOpened(asset.id, asset.title) + await runLocalProjectByUuid(project.metadata.id, parentPath, { + cloudProjectDirectoryPath, + cloudProjectId: asset.id, + cloudProjectSessionId, + }) + } catch (error) { + console.error(`Error starting hybrid project '${asset?.title ?? '(unknown)'}':`, error) + } finally { + if (asset) { + await remoteBackend.closeProject(asset.id, asset.title) + } + } +} + +/** Run a local project by UUID. */ +export async function runLocalProjectByUuid( + projectId: UUID, + projectsDirectory: Path, + cloudParams?: CloudParams, +): Promise { + const projectService = createProjectService() + try { + await projectService.runProject(projectId, projectsDirectory, cloudParams) + } catch (error) { + console.error(`Error starting local project '${projectId}':`, error) + await projectService.closeProject(projectId) + throw error + } +} + +/** Run a local project by path. */ +export async function runLocalProjectByPath(projectPath: Path): Promise { + const directoryId = Path(dirname(projectPath)) + const project = await getFileSystemEntry(projectPath) + if (project.type !== 'ProjectEntry') { + throw new Error(`The path '${projectPath}' does not point to a project.`) + } + await runLocalProjectByUuid(project.metadata.id as UUID, directoryId) +} diff --git a/build_tools/build/src/ide/web.rs b/build_tools/build/src/ide/web.rs index e0b31e754028..81277502ecdb 100644 --- a/build_tools/build/src/ide/web.rs +++ b/build_tools/build/src/ide/web.rs @@ -95,6 +95,9 @@ pub mod env { ENSO_IDE_COMMIT_HASH, String; ENSO_IDE_VERSION, String; + + /// Vite mode. + MODE, String; } } diff --git a/build_tools/build/src/project/gui.rs b/build_tools/build/src/project/gui.rs index 476f6d8f3241..c80a47662adf 100644 --- a/build_tools/build/src/project/gui.rs +++ b/build_tools/build/src/project/gui.rs @@ -100,6 +100,7 @@ impl IsTarget for Gui { .current_dir(repo_root) .set_env(ide_env::ENSO_IDE_COMMIT_HASH, &commit_hash)? .set_env(ide_env::ENSO_IDE_VERSION, &version_string)? + .set_env(ide_env::MODE, &mode.to_string())? .run("build:gui") .arg(format!("--mode={mode}")) .run_ok() diff --git a/flake.nix b/flake.nix index ac83c5e92c44..01feb391bff5 100644 --- a/flake.nix +++ b/flake.nix @@ -1,66 +1,75 @@ { inputs = { - nixpkgs.url = github:nixos/nixpkgs/nixpkgs-unstable; - fenix.url = github:nix-community/fenix; + nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + fenix.url = "github:nix-community/fenix"; fenix.inputs.nixpkgs.follows = "nixpkgs"; nixpkgs2.url = "github:nixos/nixpkgs?rev=ebf9d4445d9e916239caa8d12a510e94a6d58a2f"; }; - outputs = { self, nixpkgs, nixpkgs2, fenix }: + outputs = + { + self, + nixpkgs, + nixpkgs2, + fenix, + }: let - forAllSystems = with nixpkgs.lib; f: foldAttrs mergeAttrs { } - (map (s: { ${s} = f s; }) systems.flakeExposed); + forAllSystems = + with nixpkgs.lib; + f: foldAttrs mergeAttrs { } (map (s: { ${s} = f s; }) systems.flakeExposed); in { - devShell = forAllSystems - (system: - let - pkgs = nixpkgs.legacyPackages.${system}; - pkgs2 = nixpkgs2.legacyPackages.${system}; - rust = fenix.packages.${system}.fromToolchainFile { - dir = ./.; - sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI="; - }; - isOnLinux = pkgs.lib.hasInfix "linux" system; - rust-jni = - if isOnLinux then with fenix.packages.${system}; combine [ + devShell = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + pkgs2 = nixpkgs2.legacyPackages.${system}; + rust = fenix.packages.${system}.fromToolchainFile { + dir = ./.; + sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI="; + }; + isOnLinux = pkgs.lib.hasInfix "linux" system; + rust-jni = + if isOnLinux then + with fenix.packages.${system}; + combine [ minimal.cargo minimal.rustc targets.x86_64-unknown-linux-musl.latest.rust-std - ] else fenix.packages.${system}.minimal.toolchain; + ] + else + fenix.packages.${system}.minimal.toolchain; - # https://github.com/NixOS/nixpkgs/blob/618c81f7b15d3e2dd73d9d413d9e7b13fbc9520f/pkgs/development/tools/build-managers/bazel/bazel_7/default.nix#L58 - defaultShellUtils = with pkgs2; [ - bash - coreutils - diffutils - file - findutils - gawk - gnugrep - gnupatch - gnused - gnutar - gzip - python3 - unzip - which - zip - makeWrapper - ]; - # First: override stdenv like upstream did. - # NOTE [NP]: This workaround should not be necessary from Bazel 8.4.0 onwards. - version = "8.2.1"; - bazelBase = pkgs2.bazel_8.override { - inherit version; - stdenv = - if pkgs2.stdenv.cc.isClang - then pkgs2.llvmPackages_17.stdenv - else pkgs2.stdenv; - }; - # https://github.com/NixOS/nixpkgs/blob/618c81f7b15d3e2dd73d9d413d9e7b13fbc9520f/pkgs/development/tools/build-managers/bazel/bazel_7/default.nix#L257 - defaultShellPath = pkgs2.lib.makeBinPath defaultShellUtils; - bazel = - (bazelBase.overrideAttrs (final: prev: { + # https://github.com/NixOS/nixpkgs/blob/618c81f7b15d3e2dd73d9d413d9e7b13fbc9520f/pkgs/development/tools/build-managers/bazel/bazel_7/default.nix#L58 + defaultShellUtils = with pkgs2; [ + bash + coreutils + diffutils + file + findutils + gawk + gnugrep + gnupatch + gnused + gnutar + gzip + python3 + unzip + which + zip + makeWrapper + ]; + # First: override stdenv like upstream did. + # NOTE [NP]: This workaround should not be necessary from Bazel 8.4.0 onwards. + version = "8.2.1"; + bazelBase = pkgs2.bazel_8.override { + inherit version; + stdenv = if pkgs2.stdenv.cc.isClang then pkgs2.llvmPackages_17.stdenv else pkgs2.stdenv; + }; + # https://github.com/NixOS/nixpkgs/blob/618c81f7b15d3e2dd73d9d413d9e7b13fbc9520f/pkgs/development/tools/build-managers/bazel/bazel_7/default.nix#L257 + defaultShellPath = pkgs2.lib.makeBinPath defaultShellUtils; + bazel = ( + bazelBase.overrideAttrs ( + final: prev: { # Downgrade the version from 8.4.0 to 8.2.1 (needs to match our `.bazelversion`). inherit version; src = pkgs2.fetchzip { @@ -81,12 +90,16 @@ ''; in # Filter out upstream patches that are not compatible with our version. - # Drop the Darwin-only pathches only on Darwin; drop `deps_patches` everywhere. - (builtins.filter (p: - let matches = a: b: pkgs2.lib.hasSuffix a (baseNameOf (toString b)); - in !(matches "deps_patches.patch" p) + # Drop the Darwin-only pathches only on Darwin; drop `deps_patches` everywhere. + (builtins.filter ( + p: + let + matches = a: b: pkgs2.lib.hasSuffix a (baseNameOf (toString b)); + in + !(matches "deps_patches.patch" p) && (!isDarwin || !(matches "apple_cc_toolchain.patch" p || matches "xcode.patch" p)) - )) prev.patches + )) + prev.patches # Add our non-conditional patch replacements. ++ [ ./nix/patches/deps_patches.patch @@ -108,46 +121,50 @@ actionsPathPatch = defaultShellPath; }) ]; - })); + } + ) + ); - pnpm-shim = pkgs.writeShellScriptBin "pnpm" '' - set -euo pipefail - PACKAGE_JSON=$(git rev-parse --show-toplevel)/package.json - trap "sed -i 's#\"postinstall\": \"${bazel}/bin/bazel#\"postinstall\": \"bazel#' \"$PACKAGE_JSON\"" EXIT - sed -i 's#"postinstall": "bazel#"postinstall": "${bazel}/bin/bazel#' "$PACKAGE_JSON" - ${pkgs.corepack}/bin/pnpm "$@" - ''; - rustup-shim = pkgs.writeShellScriptBin "rustup" '' - case "$3" in - x86_64-unknown-linux-musl) - echo 'Installing Nix Rust shims' - ln -sf ${rust-jni}/bin/rustc $out/bin/rustc - ln -sf ${rust-jni}/bin/cargo $out/bin/cargo - ;; - *) - echo 'Uninstalling Nix Rust shims (if installed)' - rm -f $out/bin/{rustc,cargo} - ;; - esac - ''; + pnpm-shim = pkgs.writeShellScriptBin "pnpm" '' + set -euo pipefail + PACKAGE_JSON=$(git rev-parse --show-toplevel)/package.json + trap "sed -i 's#\"postinstall\": \"${bazel}/bin/bazel#\"postinstall\": \"bazel#' \"$PACKAGE_JSON\"" EXIT + sed -i 's#"postinstall": "bazel#"postinstall": "${bazel}/bin/bazel#' "$PACKAGE_JSON" + ${pkgs.corepack}/bin/pnpm "$@" + ''; + rustup-shim = pkgs.writeShellScriptBin "rustup" '' + case "$3" in + x86_64-unknown-linux-musl) + echo 'Installing Nix Rust shims' + ln -sf ${rust-jni}/bin/rustc $out/bin/rustc + ln -sf ${rust-jni}/bin/cargo $out/bin/cargo + ;; + *) + echo 'Uninstalling Nix Rust shims (if installed)' + rm -f $out/bin/{rustc,cargo} + ;; + esac + ''; - # Conditionally set the bazelrc only if the target system is Darwin (macOS). - bazelrc = - if pkgs.stdenv.isDarwin - then - pkgs.writeText ".bazelrc.local" '' - # These flags are dynamically generated by the Darwin flake module. - # - # Add `try-import %workspace%/.bazelrc.local` to your .bazelrc to include these - # flags when running Bazel in a nix environment. These are the libs and frameworks - # used by darwin. + # Conditionally set the bazelrc only if the target system is Darwin (macOS). + bazelrc = + if pkgs.stdenv.isDarwin then + pkgs.writeText ".bazelrc.local" '' + # These flags are dynamically generated by the Darwin flake module. + # + # Add `try-import %workspace%/.bazelrc.local` to your .bazelrc to include these + # flags when running Bazel in a nix environment. These are the libs and frameworks + # used by darwin. - build --@rules_rust//:extra_exec_rustc_flags=-L${pkgs.libiconv}/lib - '' - else ""; - in - pkgs.mkShell rec { - buildInputs = with pkgs; [ + build --@rules_rust//:extra_exec_rustc_flags=-L${pkgs.libiconv}/lib + '' + else + ""; + in + pkgs.mkShell rec { + buildInputs = + with pkgs; + [ # === Bazel === bazel # === Graal dependencies === @@ -155,43 +172,50 @@ # === Rust dependencies === openssl.dev pkg-config - ] ++ (if !isOnLinux then [ - # === macOS-specific dependencies === - libiconv # Required by `sysinfo` (via `ide_ci`). - ] else [ ]); + ] + ++ ( + if !isOnLinux then + [ + # === macOS-specific dependencies === + libiconv # Required by `sysinfo` (via `ide_ci`). + ] + else + [ ] + ); - packages = with pkgs; [ - # === Shims (highest precedence) === - pnpm-shim - rustup-shim - # === TypeScript dependencies === - nodejs_22 - corepack - # === Electron === - electron - # === node-gyp dependencies === - python3 - gnumake - # === WASM parser dependencies === - rust - ]; + packages = with pkgs; [ + # === Shims (highest precedence) === + pnpm-shim + rustup-shim + # === TypeScript dependencies === + nodejs_22 + corepack + # === Electron === + electron + # === node-gyp dependencies === + python3 + gnumake + # === WASM parser dependencies === + rust + ]; - shellHook = '' - export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH" + shellHook = '' + export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH" - if ! readlink "''${PWD}/.bazelrc.local" >/dev/null \ - || [[ $(readlink "''${PWD}/.bazelrc.local") != ${bazelrc} ]]; then - echo 1>&2 "Darwin: updating $PWD repository" - [ -L .bazelrc.local ] && unlink .bazelrc.local + if ! readlink "''${PWD}/.bazelrc.local" >/dev/null \ + || [[ $(readlink "''${PWD}/.bazelrc.local") != "${bazelrc}" ]]; then + echo 1>&2 "Darwin: updating $PWD repository" + [ -L .bazelrc.local ] && unlink .bazelrc.local - if [ -e "''${PWD}/.bazelrc.local" ]; then - echo 1>&2 "Darwin: WARNING: Refusing to install because of pre-existing .bazelrc.local" - echo 1>&2 " Remove the .bazelrc.local file and add .bazelrc.local to .gitignore." - else - ln -fs ${bazelrc} "''${PWD}/.bazelrc.local" - fi + if [ -e "''${PWD}/.bazelrc.local" ]; then + echo 1>&2 "Darwin: WARNING: Refusing to install because of pre-existing .bazelrc.local" + echo 1>&2 " Remove the .bazelrc.local file and add .bazelrc.local to .gitignore." + else + ln -fs ${bazelrc} "''${PWD}/.bazelrc.local" fi - ''; - }); + fi + ''; + } + ); }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a1a427ed771..a1d183a52144 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,7 +74,7 @@ importers: version: 2.8.1 typescript: specifier: 'catalog:' - version: 5.7.2 + version: 5.8.2 devDependencies: '@bazel/ibazel': specifier: ^0.25.0 @@ -84,13 +84,13 @@ importers: version: 9.17.0 '@typescript-eslint/eslint-plugin': specifier: ^8.19.0 - version: 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2))(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) + version: 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) '@typescript-eslint/parser': specifier: ^8.19.0 - version: 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) + version: 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) '@vue/eslint-config-typescript': specifier: ^14.2.0 - version: 14.2.0(eslint-plugin-vue@9.32.0(eslint@9.17.0(jiti@1.21.7)))(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) + version: 14.2.0(eslint-plugin-vue@9.32.0(eslint@9.17.0(jiti@1.21.7)))(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) eslint: specifier: ^9.17.0 version: 9.17.0(jiti@1.21.7) @@ -123,10 +123,10 @@ importers: version: 3.4.2 prettier-plugin-organize-imports: specifier: ^4.1.0 - version: 4.1.0(prettier@3.4.2)(typescript@5.7.2)(vue-tsc@2.2.0(typescript@5.7.2)) + version: 4.1.0(prettier@3.4.2)(typescript@5.8.2)(vue-tsc@2.2.0(typescript@5.8.2)) prettier-plugin-tailwindcss: specifier: ^0.5.14 - version: 0.5.14(@ianvs/prettier-plugin-sort-imports@4.3.0(@vue/compiler-sfc@3.5.13)(prettier@3.4.2))(prettier-plugin-organize-imports@4.1.0(prettier@3.4.2)(typescript@5.7.2)(vue-tsc@2.2.0(typescript@5.7.2)))(prettier@3.4.2) + version: 0.5.14(@ianvs/prettier-plugin-sort-imports@4.3.0(@vue/compiler-sfc@3.5.13)(prettier@3.4.2))(prettier-plugin-organize-imports@4.1.0(prettier@3.4.2)(typescript@5.8.2)(vue-tsc@2.2.0(typescript@5.8.2)))(prettier@3.4.2) app/common: dependencies: @@ -139,9 +139,12 @@ importers: is-network-error: specifier: ^1.1.0 version: 1.1.0 + vite: + specifier: 'catalog:' + version: 7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0) vue: specifier: 'catalog:' - version: 3.5.13(typescript@5.7.2) + version: 3.5.13(typescript@5.8.2) zod: specifier: 'catalog:' version: 3.25.76 @@ -149,6 +152,9 @@ importers: '@fast-check/vitest': specifier: 'catalog:' version: 0.2.1(vitest@3.2.4) + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(@types/node@24.2.1)(rollup@4.46.2)(typescript@5.8.2)(vite@7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0)) vitest: specifier: 'catalog:' version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/browser@3.2.4)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.37.0)(yaml@2.7.0) @@ -197,6 +203,9 @@ importers: tar-stream: specifier: ^3.1.7 version: 3.1.7 + vitest: + specifier: 'catalog:' + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/browser@3.2.4)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.37.0)(yaml@2.7.0) yargs: specifier: 17.6.2 version: 17.6.2 @@ -551,7 +560,7 @@ importers: version: 3.25.76 zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.18)(react@18.3.1) + version: 4.5.5(@types/react@18.3.18)(immer@9.0.6)(react@18.3.1) devDependencies: '@codemirror/theme-one-dark': specifier: ^6.1.3 @@ -1222,6 +1231,10 @@ packages: resolution: {integrity: sha512-ZjpP2gYbSFlxxaUDa1Il5AVvfggvUPbjzzB/l3q0gIE5Thd6xKW+yzEpt2mLZ5s5UaYSABZbF94g8NUOF4CVGA==} engines: {node: '>=16.0.0'} + '@aws-sdk/types@3.922.0': + resolution: {integrity: sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/url-parser-native@3.6.1': resolution: {integrity: sha512-3O+ktsrJoE8YQCho9L41YXO8EWILXrSeES7amUaV3mgIV5w4S3SB/r4RkmylpqRpQF7Ry8LFiAnMqH1wa4WBPA==} engines: {node: '>= 10.0.0'} @@ -1825,6 +1838,14 @@ packages: '@internationalized/string@3.2.5': resolution: {integrity: sha512-rKs71Zvl2OKOHM+mzAFMIyqR5hI1d1O6BBkMK2/lkfg3fkmVh9Eeg0awcA8W2WqYqDOv6a86DIOlFpggwLtbuw==} + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1945,6 +1966,19 @@ packages: resolution: {integrity: sha512-Rdi3amfFyTZoUXxMc95k9x9Ult+DtQSuOHsZwN5wtIKQ5JdXQaErgtWgGjW0Fpg4Rj0YrUCpWOj0VqsumAt5JA==} hasBin: true + '@microsoft/api-extractor-model@7.31.3': + resolution: {integrity: sha512-dv4quQI46p0U03TCEpasUf6JrJL3qjMN7JUAobsPElxBv4xayYYvWW9aPpfYV+Jx6hqUcVaLVOeV7+5hxsyoFQ==} + + '@microsoft/api-extractor@7.54.0': + resolution: {integrity: sha512-t0SEcbVUPy4yAVykPafTNWktBg728X6p9t8qCuGDsYr1/lz2VQFihYDP2CnBFSArP5vwJPcvxktoKVSqH326cA==} + hasBin: true + + '@microsoft/tsdoc-config@0.17.1': + resolution: {integrity: sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==} + + '@microsoft/tsdoc@0.15.1': + resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@monaco-editor/loader@1.4.0': resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==} peerDependencies: @@ -2571,6 +2605,15 @@ packages: '@rolldown/pluginutils@1.0.0-beta.30': resolution: {integrity: sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw==} + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.46.2': resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==} cpu: [arm] @@ -2671,6 +2714,36 @@ packages: cpu: [x64] os: [win32] + '@rushstack/node-core-library@5.18.0': + resolution: {integrity: sha512-XDebtBdw5S3SuZIt+Ra2NieT8kQ3D2Ow1HxhDQ/2soinswnOu9e7S69VSwTOLlQnx5mpWbONu+5JJjDxMAb6Fw==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/problem-matcher@0.1.1': + resolution: {integrity: sha512-Fm5XtS7+G8HLcJHCWpES5VmeMyjAKaWeyZU5qPzZC+22mPlJzAsOxymHiWIfuirtPckX3aptWws+K2d0BzniJA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.6.0': + resolution: {integrity: sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw==} + + '@rushstack/terminal@0.19.3': + resolution: {integrity: sha512-0P8G18gK9STyO+CNBvkKPnWGMxESxecTYqOcikHOVIHXa9uAuTK+Fw8TJq2Gng1w7W6wTC9uPX6hGNvrMll2wA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@5.1.3': + resolution: {integrity: sha512-Kdv0k/BnnxIYFlMVC1IxrIS0oGQd4T4b7vKfx52Y2+wk2WZSDFIvedr7JrhenzSlm3ou5KwtoTGTGd5nbODRug==} + '@sentry-internal/feedback@7.120.3': resolution: {integrity: sha512-ewJJIQ0mbsOX6jfiVFvqMjokxNtgP3dNwUv+4nenN+iJJPQsM6a0ocro3iscxwVdbkjw5hY3BUV2ICI5Q0UWoA==} engines: {node: '>=12'} @@ -2795,6 +2868,10 @@ packages: resolution: {integrity: sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg==} engines: {node: '>=16.0.0'} + '@smithy/types@4.8.1': + resolution: {integrity: sha512-N0Zn0OT1zc+NA+UVfkYqQzviRh5ucWwO7mBV3TmHHprMnfcJNfhlPicDkBHi0ewbh+y3evR6cNAW0Raxvb01NA==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -2889,6 +2966,9 @@ packages: '@tsconfig/node20@20.1.4': resolution: {integrity: sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==} + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -3404,6 +3484,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + ag-charts-community@10.3.3: resolution: {integrity: sha512-LUlbVS+4sX1UHqZNuE9m3LISr7D4FEO/Y88fwit1fYPTk2l1ZJG/82gFnm5bQ+iG4QgNIwRa2TWm7w2MLnyJlA==} @@ -3427,6 +3512,22 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -3435,6 +3536,12 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + + ajv@8.13.0: + resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -3899,6 +4006,9 @@ packages: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compress-commons@4.1.2: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} @@ -3922,6 +4032,12 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -4273,6 +4389,10 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} + diff@8.0.2: + resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + engines: {node: '>=0.3.1'} + dir-compare@3.3.0: resolution: {integrity: sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==} @@ -4638,6 +4758,9 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + ext-list@2.2.2: resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} engines: {node: '>=0.10.0'} @@ -4777,6 +4900,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -5031,10 +5158,17 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@9.0.6: + resolution: {integrity: sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==} + import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -5302,6 +5436,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-beautify@1.15.1: resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==} engines: {node: '>=14'} @@ -5449,6 +5586,10 @@ packages: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} engines: {node: '>=4'} + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + localforage@1.10.0: resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} @@ -5587,6 +5728,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -5646,6 +5791,9 @@ packages: engines: {node: '>=10'} hasBin: true + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + mocha@10.8.2: resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==} engines: {node: '>= 14.0.0'} @@ -6015,6 +6163,12 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.54.1: resolution: {integrity: sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==} engines: {node: '>=18'} @@ -6239,6 +6393,9 @@ packages: resolution: {integrity: sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==} engines: {node: '>=0.6'} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + querystring@0.2.0: resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} engines: {node: '>=0.4.x'} @@ -6510,6 +6667,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + semver@7.7.1: resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} engines: {node: '>=10'} @@ -6708,6 +6870,10 @@ packages: strict-event-emitter-types@2.0.0: resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -7001,6 +7167,14 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.8.2: + resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -7142,6 +7316,15 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-plugin-dts@4.5.4: + resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} + peerDependencies: + typescript: '*' + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite-plugin-inspect@11.3.3: resolution: {integrity: sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==} engines: {node: '>=14'} @@ -7646,7 +7829,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.714.0 + '@aws-sdk/types': 3.922.0 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@1.0.0': @@ -7661,7 +7844,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.714.0 + '@aws-sdk/types': 3.922.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -7881,6 +8064,11 @@ snapshots: '@smithy/types': 3.7.2 tslib: 2.8.1 + '@aws-sdk/types@3.922.0': + dependencies: + '@smithy/types': 4.8.1 + tslib: 2.8.1 + '@aws-sdk/url-parser-native@3.6.1': dependencies: '@aws-sdk/querystring-parser': 3.6.1 @@ -8584,6 +8772,12 @@ snapshots: dependencies: '@swc/helpers': 0.5.15 + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -8762,8 +8956,8 @@ snapshots: acorn: 8.14.0 acorn-walk: 8.3.4 rollup: 4.46.2 - rollup-plugin-dts: 6.1.1(rollup@4.46.2)(typescript@5.7.2) - typescript: 5.7.2 + rollup-plugin-dts: 6.1.1(rollup@4.46.2)(typescript@5.8.2) + typescript: 5.8.2 '@marijn/find-cluster-break@1.0.2': {} @@ -8779,6 +8973,42 @@ snapshots: - supports-color - utf-8-validate + '@microsoft/api-extractor-model@7.31.3(@types/node@24.2.1)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.18.0(@types/node@24.2.1) + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.54.0(@types/node@24.2.1)': + dependencies: + '@microsoft/api-extractor-model': 7.31.3(@types/node@24.2.1) + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.18.0(@types/node@24.2.1) + '@rushstack/rig-package': 0.6.0 + '@rushstack/terminal': 0.19.3(@types/node@24.2.1) + '@rushstack/ts-command-line': 5.1.3(@types/node@24.2.1) + diff: 8.0.2 + lodash: 4.17.21 + minimatch: 10.0.3 + resolve: 1.22.10 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.8.2 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.17.1': + dependencies: + '@microsoft/tsdoc': 0.15.1 + ajv: 8.12.0 + jju: 1.4.0 + resolve: 1.22.10 + + '@microsoft/tsdoc@0.15.1': {} + '@monaco-editor/loader@1.4.0(monaco-editor@0.48.0)': dependencies: monaco-editor: 0.48.0 @@ -9882,6 +10112,14 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.30': {} + '@rollup/pluginutils@5.3.0(rollup@4.46.2)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.46.2 + '@rollup/rollup-android-arm-eabi@4.46.2': optional: true @@ -9942,6 +10180,45 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.46.2': optional: true + '@rushstack/node-core-library@5.18.0(@types/node@24.2.1)': + dependencies: + ajv: 8.13.0 + ajv-draft-04: 1.0.0(ajv@8.13.0) + ajv-formats: 3.0.1(ajv@8.13.0) + fs-extra: 11.3.2 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.10 + semver: 7.5.4 + optionalDependencies: + '@types/node': 24.2.1 + + '@rushstack/problem-matcher@0.1.1(@types/node@24.2.1)': + optionalDependencies: + '@types/node': 24.2.1 + + '@rushstack/rig-package@0.6.0': + dependencies: + resolve: 1.22.10 + strip-json-comments: 3.1.1 + + '@rushstack/terminal@0.19.3(@types/node@24.2.1)': + dependencies: + '@rushstack/node-core-library': 5.18.0(@types/node@24.2.1) + '@rushstack/problem-matcher': 0.1.1(@types/node@24.2.1) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 24.2.1 + + '@rushstack/ts-command-line@5.1.3(@types/node@24.2.1)': + dependencies: + '@rushstack/terminal': 0.19.3(@types/node@24.2.1) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + '@sentry-internal/feedback@7.120.3': dependencies: '@sentry/core': 7.120.3 @@ -10087,6 +10364,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.8.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -10183,6 +10464,8 @@ snapshots: '@tsconfig/node20@20.1.4': {} + '@types/argparse@1.0.38': {} + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -10489,32 +10772,32 @@ snapshots: '@types/node': 24.2.1 '@types/readable-stream': 4.0.21 - '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2))(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2)': + '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) + '@typescript-eslint/parser': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) '@typescript-eslint/scope-manager': 8.19.0 - '@typescript-eslint/type-utils': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) - '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) + '@typescript-eslint/type-utils': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) + '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) '@typescript-eslint/visitor-keys': 8.19.0 eslint: 9.17.0(jiti@1.21.7) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.4.3(typescript@5.7.2) - typescript: 5.7.2 + ts-api-utils: 1.4.3(typescript@5.8.2) + typescript: 5.8.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2)': + '@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2)': dependencies: '@typescript-eslint/scope-manager': 8.19.0 '@typescript-eslint/types': 8.19.0 - '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.7.2) + '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.8.2) '@typescript-eslint/visitor-keys': 8.19.0 debug: 4.4.1(supports-color@8.1.1) eslint: 9.17.0(jiti@1.21.7) - typescript: 5.7.2 + typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -10523,20 +10806,20 @@ snapshots: '@typescript-eslint/types': 8.19.0 '@typescript-eslint/visitor-keys': 8.19.0 - '@typescript-eslint/type-utils@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2)': + '@typescript-eslint/type-utils@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2)': dependencies: - '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.7.2) - '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) + '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.8.2) + '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) debug: 4.4.1(supports-color@8.1.1) eslint: 9.17.0(jiti@1.21.7) - ts-api-utils: 1.4.3(typescript@5.7.2) - typescript: 5.7.2 + ts-api-utils: 1.4.3(typescript@5.8.2) + typescript: 5.8.2 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.19.0': {} - '@typescript-eslint/typescript-estree@8.19.0(typescript@5.7.2)': + '@typescript-eslint/typescript-estree@8.19.0(typescript@5.8.2)': dependencies: '@typescript-eslint/types': 8.19.0 '@typescript-eslint/visitor-keys': 8.19.0 @@ -10545,19 +10828,19 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.1 - ts-api-utils: 1.4.3(typescript@5.7.2) - typescript: 5.7.2 + ts-api-utils: 1.4.3(typescript@5.8.2) + typescript: 5.8.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2)': + '@typescript-eslint/utils@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@1.21.7)) '@typescript-eslint/scope-manager': 8.19.0 '@typescript-eslint/types': 8.19.0 - '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.7.2) + '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.8.2) eslint: 9.17.0(jiti@1.21.7) - typescript: 5.7.2 + typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -10769,15 +11052,15 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/eslint-config-typescript@14.2.0(eslint-plugin-vue@9.32.0(eslint@9.17.0(jiti@1.21.7)))(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2)': + '@vue/eslint-config-typescript@14.2.0(eslint-plugin-vue@9.32.0(eslint@9.17.0(jiti@1.21.7)))(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2)': dependencies: eslint: 9.17.0(jiti@1.21.7) eslint-plugin-vue: 9.32.0(eslint@9.17.0(jiti@1.21.7)) fast-glob: 3.3.2 - typescript-eslint: 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) + typescript-eslint: 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) vue-eslint-parser: 9.4.3(eslint@9.17.0(jiti@1.21.7)) optionalDependencies: - typescript: 5.7.2 + typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -10794,6 +11077,19 @@ snapshots: optionalDependencies: typescript: 5.7.2 + '@vue/language-core@2.2.0(typescript@5.8.2)': + dependencies: + '@volar/language-core': 2.4.11 + '@vue/compiler-dom': 3.5.13 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.13 + alien-signals: 0.4.12 + minimatch: 9.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.2 + '@vue/reactivity@3.5.13': dependencies: '@vue/shared': 3.5.13 @@ -10816,6 +11112,12 @@ snapshots: '@vue/shared': 3.5.13 vue: 3.5.13(typescript@5.7.2) + '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.8.2))': + dependencies: + '@vue/compiler-ssr': 3.5.13 + '@vue/shared': 3.5.13 + vue: 3.5.13(typescript@5.8.2) + '@vue/shared@3.5.13': {} '@vue/test-utils@2.4.6(typescript@5.7.2)': @@ -10874,6 +11176,8 @@ snapshots: acorn@8.14.0: {} + acorn@8.15.0: {} + ag-charts-community@10.3.3: dependencies: ag-charts-locale: 10.3.3 @@ -10900,6 +11204,14 @@ snapshots: agent-base@7.1.3: {} + ajv-draft-04@1.0.0(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + + ajv-formats@3.0.1(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 @@ -10911,6 +11223,20 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.13.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -11476,6 +11802,8 @@ snapshots: compare-version@0.1.2: {} + compare-versions@6.1.1: {} + compress-commons@4.1.2: dependencies: buffer-crc32: 0.2.13 @@ -11516,6 +11844,10 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + confbox@0.1.8: {} + + confbox@0.2.2: {} + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -11524,7 +11856,7 @@ snapshots: config-file-ts@0.2.6: dependencies: glob: 10.4.5 - typescript: 5.7.2 + typescript: 5.8.2 connect@3.7.0: dependencies: @@ -11879,6 +12211,8 @@ snapshots: diff@5.2.0: {} + diff@8.0.2: {} + dir-compare@3.3.0: dependencies: buffer-equal: 1.0.1 @@ -12445,6 +12779,8 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.0.7: {} + ext-list@2.2.2: dependencies: mime-db: 1.52.0 @@ -12598,6 +12934,12 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -12902,11 +13244,16 @@ snapshots: immediate@3.0.6: {} + immer@9.0.6: + optional: true + import-fresh@3.3.0: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 + import-lazy@4.0.0: {} + imurmurhash@0.1.4: {} indefinitely-typed@1.1.0: @@ -13153,6 +13500,8 @@ snapshots: jiti@1.21.7: {} + jju@1.4.0: {} + js-beautify@1.15.1: dependencies: config-chain: 1.1.13 @@ -13315,6 +13664,12 @@ snapshots: pify: 3.0.0 strip-bom: 3.0.0 + local-pkg@1.1.2: + dependencies: + mlly: 1.8.0 + pkg-types: 2.3.0 + quansync: 0.2.11 + localforage@1.10.0: dependencies: lie: 3.1.1 @@ -13418,6 +13773,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.0.3: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -13468,6 +13827,13 @@ snapshots: mkdirp@1.0.4: {} + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + mocha@10.8.2: dependencies: ansi-colors: 4.1.3 @@ -13825,6 +14191,18 @@ snapshots: pirates@4.0.6: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.7 + pathe: 2.0.3 + playwright-core@1.54.1: {} playwright@1.54.1: @@ -13906,19 +14284,19 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier-plugin-organize-imports@4.1.0(prettier@3.4.2)(typescript@5.7.2)(vue-tsc@2.2.0(typescript@5.7.2)): + prettier-plugin-organize-imports@4.1.0(prettier@3.4.2)(typescript@5.8.2)(vue-tsc@2.2.0(typescript@5.8.2)): dependencies: prettier: 3.4.2 - typescript: 5.7.2 + typescript: 5.8.2 optionalDependencies: - vue-tsc: 2.2.0(typescript@5.7.2) + vue-tsc: 2.2.0(typescript@5.8.2) - prettier-plugin-tailwindcss@0.5.14(@ianvs/prettier-plugin-sort-imports@4.3.0(@vue/compiler-sfc@3.5.13)(prettier@3.4.2))(prettier-plugin-organize-imports@4.1.0(prettier@3.4.2)(typescript@5.7.2)(vue-tsc@2.2.0(typescript@5.7.2)))(prettier@3.4.2): + prettier-plugin-tailwindcss@0.5.14(@ianvs/prettier-plugin-sort-imports@4.3.0(@vue/compiler-sfc@3.5.13)(prettier@3.4.2))(prettier-plugin-organize-imports@4.1.0(prettier@3.4.2)(typescript@5.8.2)(vue-tsc@2.2.0(typescript@5.8.2)))(prettier@3.4.2): dependencies: prettier: 3.4.2 optionalDependencies: '@ianvs/prettier-plugin-sort-imports': 4.3.0(@vue/compiler-sfc@3.5.13)(prettier@3.4.2) - prettier-plugin-organize-imports: 4.1.0(prettier@3.4.2)(typescript@5.7.2)(vue-tsc@2.2.0(typescript@5.7.2)) + prettier-plugin-organize-imports: 4.1.0(prettier@3.4.2)(typescript@5.8.2)(vue-tsc@2.2.0(typescript@5.8.2)) prettier@3.4.2: {} @@ -13996,6 +14374,8 @@ snapshots: dependencies: side-channel: 1.1.0 + quansync@0.2.11: {} + querystring@0.2.0: {} queue-microtask@1.2.3: {} @@ -14303,11 +14683,11 @@ snapshots: robust-predicates@3.0.2: {} - rollup-plugin-dts@6.1.1(rollup@4.46.2)(typescript@5.7.2): + rollup-plugin-dts@6.1.1(rollup@4.46.2)(typescript@5.8.2): dependencies: magic-string: 0.30.17 rollup: 4.46.2 - typescript: 5.7.2 + typescript: 5.8.2 optionalDependencies: '@babel/code-frame': 7.27.1 @@ -14405,6 +14785,10 @@ snapshots: semver@6.3.1: {} + semver@7.5.4: + dependencies: + lru-cache: 6.0.0 + semver@7.7.1: {} send@0.19.0: @@ -14630,6 +15014,8 @@ snapshots: strict-event-emitter-types@2.0.0: {} + string-argv@0.3.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -14849,7 +15235,7 @@ snapshots: terser@5.37.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true @@ -14932,9 +15318,9 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 - ts-api-utils@1.4.3(typescript@5.7.2): + ts-api-utils@1.4.3(typescript@5.8.2): dependencies: - typescript: 5.7.2 + typescript: 5.8.2 ts-interface-checker@0.1.13: {} @@ -14991,18 +15377,22 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2): + typescript-eslint@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2))(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) - '@typescript-eslint/parser': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) - '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.2) + '@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) + '@typescript-eslint/parser': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) + '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.7))(typescript@5.8.2) eslint: 9.17.0(jiti@1.21.7) - typescript: 5.7.2 + typescript: 5.8.2 transitivePeerDependencies: - supports-color typescript@5.7.2: {} + typescript@5.8.2: {} + + ufo@1.6.1: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.3 @@ -15193,6 +15583,25 @@ snapshots: - tsx - yaml + vite-plugin-dts@4.5.4(@types/node@24.2.1)(rollup@4.46.2)(typescript@5.8.2)(vite@7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0)): + dependencies: + '@microsoft/api-extractor': 7.54.0(@types/node@24.2.1) + '@rollup/pluginutils': 5.3.0(rollup@4.46.2) + '@volar/typescript': 2.4.11 + '@vue/language-core': 2.2.0(typescript@5.8.2) + compare-versions: 6.1.1 + debug: 4.4.1(supports-color@8.1.1) + kolorist: 1.8.0 + local-pkg: 1.1.2 + magic-string: 0.30.17 + typescript: 5.8.2 + optionalDependencies: + vite: 7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-inspect@11.3.3(vite@7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0)): dependencies: ansis: 4.1.0 @@ -15348,6 +15757,13 @@ snapshots: '@vue/language-core': 2.2.0(typescript@5.7.2) typescript: 5.7.2 + vue-tsc@2.2.0(typescript@5.8.2): + dependencies: + '@volar/typescript': 2.4.11 + '@vue/language-core': 2.2.0(typescript@5.8.2) + typescript: 5.8.2 + optional: true + vue@3.5.13(typescript@5.7.2): dependencies: '@vue/compiler-dom': 3.5.13 @@ -15358,6 +15774,16 @@ snapshots: optionalDependencies: typescript: 5.7.2 + vue@3.5.13(typescript@5.8.2): + dependencies: + '@vue/compiler-dom': 3.5.13 + '@vue/compiler-sfc': 3.5.13 + '@vue/runtime-dom': 3.5.13 + '@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.8.2)) + '@vue/shared': 3.5.13 + optionalDependencies: + typescript: 5.8.2 + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: @@ -15583,9 +16009,10 @@ snapshots: zod@3.25.76: {} - zustand@4.5.5(@types/react@18.3.18)(react@18.3.1): + zustand@4.5.5(@types/react@18.3.18)(immer@9.0.6)(react@18.3.1): dependencies: use-sync-external-store: 1.2.2(react@18.3.1) optionalDependencies: '@types/react': 18.3.18 + immer: 9.0.6 react: 18.3.1