diff --git a/.github/workflows/update-lsp-pins.yml b/.github/workflows/update-lsp-pins.yml new file mode 100644 index 0000000000..89bff9ee04 --- /dev/null +++ b/.github/workflows/update-lsp-pins.yml @@ -0,0 +1,49 @@ +name: Update pinned LSP server versions + +on: + schedule: + # monthly - 00:00 UTC on the 1st + - cron: '0 0 1 * *' + workflow_dispatch: + +jobs: + update-pins: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + with: + ref: main + - name: Update built-in LSP server versions + run: | + npm run updateBuiltInLSPS + git status + shell: bash + + - name: Create update-lsp-pins Pull Request + id: cpr + uses: peter-evans/create-pull-request@v4 + with: + commit-message: 'chore: update built-in LSP server versions' + committer: GitHub + author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com> + title: '[Release BOT] Update built-in LSP server versions' + branch: bot/update-lsp-pins + add-paths: | + src/config.json + src-node/package.json + src-node/package-lock.json + body: | + Updates every built-in language server to its latest stable release: + - runtime-installed pins in `src/config.json` (`lsp_server_pins`): intelephense (npm), + pyrefly and ruff (PyPI) + - bundled servers in `src-node/package.json` (+ lockfile): @vtsls/language-server (TS/JS), + vscode-langservers-extracted (JSON) + + The TS/JSON/PHP/Python LSP integration suites exercise these exact versions for real, + so a green CI on this PR means the new versions actually work. Merge only when green. + - Auto-generated by `update-lsp-pins.yml` action + - name: Check outputs + if: ${{ steps.cpr.outputs.pull-request-number }} + run: | + echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}" + echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}" diff --git a/build/update-lsp-pins.js b/build/update-lsp-pins.js new file mode 100644 index 0000000000..a0d4b0cf16 --- /dev/null +++ b/build/update-lsp-pins.js @@ -0,0 +1,150 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/* + * Updates every built-in language server to its latest STABLE release: + * - runtime-installed pins in src/config.json (config.lsp_server_pins): intelephense (npm), + * pyrefly and ruff (PyPI), and + * - bundled servers in src-node/package.json: @vtsls/language-server (TS/JS) and + * vscode-langservers-extracted (JSON) - written as EXACT versions, with the src-node + * lockfile regenerated so `npm ci` stays green. + * + * Prereleases are never picked up: npm's `latest` dist-tag is stable by convention and any + * version carrying a prerelease suffix is skipped with a warning. + * + * Run by the scheduled update-lsp-pins GitHub workflow (npm run updateBuiltInLSPS), which opens + * a PR with the diff - the TS/JSON/PHP/Python LSP integration suites exercise these exact + * versions for real, so CI green on that PR means the new versions actually work before they + * ship. Never run as part of normal builds: pins must not drift under a developer mid-work. + * + * Usage: node build/update-lsp-pins.js [--dry-run] + */ + +/* eslint-env node */ + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const CONFIG_PATH = path.join(__dirname, "..", "src", "config.json"); +const SRC_NODE_DIR = path.join(__dirname, "..", "src-node"); +const SRC_NODE_PKG_PATH = path.join(SRC_NODE_DIR, "package.json"); +const DRY_RUN = process.argv.includes("--dry-run"); + +// bundled servers shipped inside src-node - bumped in src-node/package.json as exact versions +const BUNDLED_LSP_PACKAGES = ["@vtsls/language-server", "vscode-langservers-extracted"]; + +async function _fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error("HTTP " + response.status + " for " + url); + } + return response.json(); +} + +// npm's `latest` dist-tag - the stable channel by convention (prereleases live on other tags) +async function latestNpmVersion(pkg) { + const meta = await _fetchJson("https://registry.npmjs.org/" + encodeURIComponent(pkg) + "/latest"); + return meta.version; +} + +async function latestPyPIVersion(pkg) { + const meta = await _fetchJson("https://pypi.org/pypi/" + pkg + "/json"); + return meta.info.version; +} + +function _isPrerelease(version) { + return version.indexOf("-") !== -1; +} + +async function updateRuntimePins() { + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + const pins = config.config.lsp_server_pins; + if (!pins) { + throw new Error("config.lsp_server_pins missing in " + CONFIG_PATH); + } + const latest = { + intelephense: await latestNpmVersion("intelephense"), + pyrefly: await latestPyPIVersion("pyrefly"), + ruff: await latestPyPIVersion("ruff") + }; + let changed = false; + for (const key of Object.keys(latest)) { + if (_isPrerelease(latest[key])) { + console.warn(key + ": skipping prerelease " + latest[key] + ", keeping " + pins[key]); + } else if (pins[key] !== latest[key]) { + console.log(key + ": " + pins[key] + " -> " + latest[key]); + pins[key] = latest[key]; + changed = true; + } else { + console.log(key + ": " + pins[key] + " (up to date)"); + } + } + if (!changed || DRY_RUN) { + if (changed) { + console.log("--dry-run: not writing " + CONFIG_PATH); + } + return; + } + // no trailing newline - matches the existing file and the gulp config writer exactly + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 4), "utf8"); + console.log("Updated " + CONFIG_PATH); +} + +async function updateBundledServers() { + const pkgJson = JSON.parse(fs.readFileSync(SRC_NODE_PKG_PATH, "utf8")); + let changed = false; + for (const pkg of BUNDLED_LSP_PACKAGES) { + const current = pkgJson.dependencies[pkg]; + if (!current) { + throw new Error(pkg + " missing from src-node/package.json dependencies"); + } + const latest = await latestNpmVersion(pkg); + if (_isPrerelease(latest)) { + console.warn(pkg + ": skipping prerelease " + latest + ", keeping " + current); + } else if (current !== latest) { + console.log(pkg + ": " + current + " -> " + latest); + pkgJson.dependencies[pkg] = latest; + changed = true; + } else { + console.log(pkg + ": " + current + " (up to date)"); + } + } + if (!changed || DRY_RUN) { + if (changed) { + console.log("--dry-run: not writing " + SRC_NODE_PKG_PATH); + } + return; + } + fs.writeFileSync(SRC_NODE_PKG_PATH, JSON.stringify(pkgJson, null, 4) + "\n", "utf8"); + console.log("Updated " + SRC_NODE_PKG_PATH); + // keep the committed lockfile in sync or `npm ci` fails on manifest mismatch; + // --package-lock-only avoids touching node_modules + console.log("Regenerating src-node/package-lock.json ..."); + execFileSync("npm", ["install", "--package-lock-only"], { cwd: SRC_NODE_DIR, stdio: "inherit" }); +} + +(async function main() { + await updateRuntimePins(); + await updateBundledServers(); +}()).catch(function (err) { + console.error(err); + process.exit(1); +}); diff --git a/docs/API-Reference/utils/NodeUtils.md b/docs/API-Reference/utils/NodeUtils.md index e9a44eb11d..5a1acd91b6 100644 --- a/docs/API-Reference/utils/NodeUtils.md +++ b/docs/API-Reference/utils/NodeUtils.md @@ -56,15 +56,34 @@ This is only available in the native app. | url | string | | browserName | string | + + +## \_npmInstallInFolder(moduleNativeDir) ⇒ [CancellablePromise](#CancellablePromise) +Runs the bundled npm install in the given folder. Rejects if an install is already in +progress there (two npm processes on one node_modules corrupt each other). + +The returned promise carries a `cancel()` method - cancellation is only meaningful for +the specific install you started, so it lives on the handle rather than as a module API. +Cancelling kills the npm process; the promise then rejects with a "cancelled" error. + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| moduleNativeDir | string | platform path of the folder holding package.json | + -## downloadFile(url, destFile, [options]) ⇒ Promise.<void> +## downloadFile(url, destFile, [options]) ⇒ [CancellablePromise](#CancellablePromise) Downloads a URL to a file on disk, fully node-side (native fetch, streamed to disk). When an expected sha256 is given, a mismatch deletes the file and rejects - a resolved promise means the file holds exactly the pinned bytes. This is only available in the native app. **Kind**: global function +**Returns**: [CancellablePromise](#CancellablePromise) - the returned promise carries a `cancel()` method that aborts + the download mid-stream - the promise then rejects with a "cancelled" error and the + partial file is deleted | Param | Type | Description | | --- | --- | --- | @@ -239,3 +258,16 @@ Retrieves the directory path for system settings. This method is applicable to n - Error If the method is called in browser app. + + +## CancellablePromise : Promise +A Promise that additionally carries a `cancel()` method aborting the underlying +operation - the promise then rejects with a "cancelled" error. + +**Kind**: global typedef +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| cancel | function | aborts the in-flight operation | + diff --git a/package.json b/package.json index 356dd413f3..47c614516b 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "prepare": "husky install", "_serveTest": "http-server . -p 5000 -c-1", "zipTestFiles": "gulp zipTestFiles", + "updateBuiltInLSPS": "node build/update-lsp-pins.js", "test": "echo please see `Running and debugging tests` section in readme.md", "testIntegHelp": "echo By default this command only runs unit tests.To Run integration tests, please see `Running and debugging tests` section in readme.md", "testChromium": "npm run testIntegHelp && npx playwright test --project=chromium", diff --git a/src-node/utils.js b/src-node/utils.js index 005f166427..95cba46064 100644 --- a/src-node/utils.js +++ b/src-node/utils.js @@ -107,6 +107,11 @@ async function _loadNodeExtensionModule({moduleNativeDir}) { require(moduleNativeDir); } +// In-flight cancellable operations - see cancelDownload / _cancelNpmInstall. Download entries +// hold an AbortController, npm entries hold the spawned ChildProcess. +const activeDownloads = new Map(); // cancelId -> AbortController +const activeNpmInstalls = new Map(); // moduleNativeDir -> ChildProcess + /** * Installs npm modules in the specified folder. * @@ -115,40 +120,89 @@ async function _loadNodeExtensionModule({moduleNativeDir}) { * @private */ async function _npmInstallInFolder({moduleNativeDir}) { - const phnodeExePath = process.argv[0]; - const npmPath = path.resolve(path.dirname(require.resolve("npm")), "bin", "npm-cli.js"); - console.log("npm path", npmPath, "phnode path", phnodeExePath); - // Check if the package.json file exists in the moduleNativeDir - // Check if the package.json file exists in the moduleNativeDir - const packageJsonPath = path.join(moduleNativeDir, 'package.json'); - await fsPromise.access(packageJsonPath); // Throws if package.json doesn't exist - - // Check if package-lock.json exists in the moduleNativeDir - const packageLockJsonPath = path.join(moduleNativeDir, 'package-lock.json'); - let packageLockJsonExists = false; - try { - await fsPromise.access(packageLockJsonPath); - packageLockJsonExists = true; - } catch (error) { - console.log("package-lock.json does not exist, it is recommended to check in package-lock.json," + - " using npm install instead of npm ci", packageLockJsonPath); + if (activeNpmInstalls.has(moduleNativeDir)) { + // two npm processes writing the same node_modules corrupt each other - callers must + // await (or cancel) the running install before starting another. Deliberately BEFORE the + // try/finally below: this throw must not clean up the entry the running install owns. + throw new Error("npm install already in progress in " + moduleNativeDir); } + try { + const phnodeExePath = process.argv[0]; + const npmPath = path.resolve(path.dirname(require.resolve("npm")), "bin", "npm-cli.js"); + console.log("npm path", npmPath, "phnode path", phnodeExePath); + // Check if the package.json file exists in the moduleNativeDir + const packageJsonPath = path.join(moduleNativeDir, 'package.json'); + await fsPromise.access(packageJsonPath); // Throws if package.json doesn't exist + + // Check if package-lock.json exists in the moduleNativeDir + const packageLockJsonPath = path.join(moduleNativeDir, 'package-lock.json'); + let packageLockJsonExists = false; + try { + await fsPromise.access(packageLockJsonPath); + packageLockJsonExists = true; + } catch (error) { + console.log("package-lock.json does not exist, it is recommended to check in package-lock.json," + + " using npm install instead of npm ci", packageLockJsonPath); + } - const npmInstallMode = packageLockJsonExists ? 'ci' : 'install'; + const npmInstallMode = packageLockJsonExists ? 'ci' : 'install'; - const nodeArgs = [npmPath, npmInstallMode, moduleNativeDir]; - return new Promise((resolve, reject) => { + const nodeArgs = [npmPath, npmInstallMode, moduleNativeDir]; console.log(`Running "${phnodeExePath} ${nodeArgs}" in ${moduleNativeDir}`); - execFile(phnodeExePath, nodeArgs, { cwd: moduleNativeDir }, (error) => { - if (error) { - console.error('Error:', error); - reject(error); - } else { - resolve(); - console.log(`Successfully ran "${nodeArgs}" in ${moduleNativeDir}`); - } + const npmInstallPromise = new Promise((resolve, reject) => { + const child = execFile(phnodeExePath, nodeArgs, { cwd: moduleNativeDir }, (error) => { + const wasCancelled = child.__phCancelled; + if (error) { + console.error('Error:', error); + if (wasCancelled) { + error.cancelled = true; + } + reject(error); + } else { + resolve(); + console.log(`Successfully ran "${nodeArgs}" in ${moduleNativeDir}`); + } + }); + // Keep the handle so _cancelNpmInstall can kill a runaway/user-cancelled install. + activeNpmInstalls.set(moduleNativeDir, child); }); - }); + // awaited (not returned raw) so the finally below runs only after npm actually exits + return await npmInstallPromise; + } finally { + // The entry MUST never outlive this invocation, whatever the failure path - a stale + // entry would make the duplicate-install guard above block every retry until restart. + activeNpmInstalls.delete(moduleNativeDir); + } +} + +/** + * Cancel an in-flight downloadFile run by the cancelId it was started with. Idempotent - + * unknown ids are a no-op. The cancelled download's promise rejects with an error carrying + * `cancelled: true` and its partial file is deleted. + * + * @param {string} cancelId - the cancelId a downloadFile call was started with + * @return {Promise} + */ +async function cancelDownload({cancelId}) { + if (cancelId && activeDownloads.has(cancelId)) { + activeDownloads.get(cancelId).abort(); + } +} + +/** + * Kill an in-flight _npmInstallInFolder run by the moduleNativeDir it was started with. + * Idempotent - unknown dirs are a no-op. The killed install's promise rejects with an error + * carrying `cancelled: true`. + * + * @param {string} moduleNativeDir - the dir an _npmInstallInFolder call was started with + * @return {Promise} + */ +async function _cancelNpmInstall({moduleNativeDir}) { + if (moduleNativeDir && activeNpmInstalls.has(moduleNativeDir)) { + const child = activeNpmInstalls.get(moduleNativeDir); + child.__phCancelled = true; + child.kill(); + } } // no dot in the name - EventDispatcher treats anything after a "." as a listener namespace @@ -163,38 +217,64 @@ const DOWNLOAD_PROGRESS_EVENT = "downloadProgress"; * @param {string} url - download URL * @param {string} destFile - platform path to write (parent directories created) * @param {string} [sha256] - expected hex digest of the downloaded bytes + * @param {string} [cancelId] - registers the download so cancelDownload({cancelId}) can + * abort it mid-stream; the partial file is deleted and the rejection error carries + * `cancelled: true` * @return {Promise} */ -async function downloadFile({url, destFile, sha256}) { - const response = await fetch(url, { redirect: "follow" }); - if (!response.ok) { - throw new Error("Download failed with HTTP " + response.status + " for " + url); +async function downloadFile({url, destFile, sha256, cancelId}) { + let controller = null; + if (cancelId) { + if (activeDownloads.has(cancelId)) { + throw new Error("a download with cancelId " + cancelId + " is already in progress"); + } + controller = new AbortController(); + activeDownloads.set(cancelId, controller); } - const total = parseInt(response.headers.get("content-length"), 10) || 0; - await fsPromise.mkdir(path.dirname(destFile), { recursive: true }); - const fileHandle = await fsPromise.open(destFile, "w"); - const hash = crypto.createHash("sha256"); - let transferred = 0; - let lastReported = 0; try { - for await (const chunk of response.body) { - await fileHandle.write(chunk); - hash.update(chunk); - transferred += chunk.length; - // throttle the websocket chatter: one event per 256KB is plenty for a progress bar - if (transferred - lastReported > 262144 || transferred === total) { - lastReported = transferred; - utilsConnector.triggerPeer(DOWNLOAD_PROGRESS_EVENT, {url, transferred, total}); + const response = await fetch(url, { + redirect: "follow", + signal: controller ? controller.signal : undefined + }); + if (!response.ok) { + throw new Error("Download failed with HTTP " + response.status + " for " + url); + } + const total = parseInt(response.headers.get("content-length"), 10) || 0; + await fsPromise.mkdir(path.dirname(destFile), { recursive: true }); + const fileHandle = await fsPromise.open(destFile, "w"); + const hash = crypto.createHash("sha256"); + let transferred = 0; + let lastReported = 0; + try { + for await (const chunk of response.body) { + await fileHandle.write(chunk); + hash.update(chunk); + transferred += chunk.length; + // throttle the websocket chatter: one event per 256KB is plenty for a progress bar + if (transferred - lastReported > 262144 || transferred === total) { + lastReported = transferred; + utilsConnector.triggerPeer(DOWNLOAD_PROGRESS_EVENT, {url, transferred, total}); + } } + } finally { + await fileHandle.close(); } - } finally { - await fileHandle.close(); - } - if (sha256) { - const actual = hash.digest("hex"); - if (actual !== sha256.toLowerCase()) { + if (sha256) { + const actual = hash.digest("hex"); + if (actual !== sha256.toLowerCase()) { + await fsPromise.unlink(destFile).catch(() => {}); + throw new Error("sha256 mismatch for " + url + " - expected " + sha256 + " got " + actual); + } + } + } catch (err) { + if (err && err.name === "AbortError") { await fsPromise.unlink(destFile).catch(() => {}); - throw new Error("sha256 mismatch for " + url + " - expected " + sha256 + " got " + actual); + err.cancelled = true; + } + throw err; + } finally { + if (cancelId) { + activeDownloads.delete(cancelId); } } } @@ -505,6 +585,8 @@ exports.getOSUserName = getOSUserName; exports.getSystemSettingsDir = getSystemSettingsDir; exports._loadNodeExtensionModule = _loadNodeExtensionModule; exports._npmInstallInFolder = _npmInstallInFolder; +exports._cancelNpmInstall = _cancelNpmInstall; +exports.cancelDownload = cancelDownload; exports.downloadFile = downloadFile; exports.extractZipFile = extractZipFile; exports.setExecutableBits = setExecutableBits; diff --git a/src/config.json b/src/config.json index 4055358e6b..786149e0fb 100644 --- a/src/config.json +++ b/src/config.json @@ -33,6 +33,11 @@ "app_notification_url": "assets/notifications/dev/", "app_update_url": "https://updates.phcode.io/tauri/update-latest-experimental-build.json", "extensionTakedownURL": "https://updates.phcode.io/extension_takedown.json", + "lsp_server_pins": { + "intelephense": "1.18.5", + "pyrefly": "1.1.1", + "ruff": "0.15.20" + }, "linting.enabled_by_default": true, "build_timestamp": "", "googleAnalyticsID": "G-P4HJFPDB76", diff --git a/src/extensions/default/PHPSupport/ServerInstaller.js b/src/extensions/default/PHPSupport/ServerInstaller.js index 35227102ab..c495c499d4 100644 --- a/src/extensions/default/PHPSupport/ServerInstaller.js +++ b/src/extensions/default/PHPSupport/ServerInstaller.js @@ -22,13 +22,16 @@ * ServerInstaller - on-demand acquisition of the Intelephense PHP language server. * * Intelephense is proprietary freeware: its license permits individual users to pair it with an - * LSP-capable editor but forbids redistributing/bundling it. So it is NOT part of the app - the - * user consents via an unobtrusive prompt (a toast plus a Problems-panel row, mirroring the - * TypeScript enable flow) and a pinned version is then npm-installed from the public registry - * into the app-data directory (the user acquires it personally - the license's intended use). - * Installation runs as a status-bar TASK (TaskManager) with progress, using the bundled npm via - * the existing node plumbing (NodeUtils._npmInstallInFolder) - no system npm or PHP runtime - * needed. Works identically on Windows, macOS and Linux (no shell, no .bin shims). + * LSP-capable editor but forbids redistributing/bundling it. So it is NOT part of the app - a + * pinned version is npm-installed AUTOMATICALLY on the first php file opened (autoInstall) from + * the public registry into the app-data directory (the user's machine acquires it locally - the + * license's intended use). Installation runs as a status-bar TASK (TaskManager) whose popup is + * opened so the download is always visible, with a stop (x) icon to cancel (cancel = no retry + * until the next app launch; the master pref is the durable opt-out). It uses the bundled npm + * via the existing node plumbing (NodeUtils._npmInstallInFolder) - no system npm or PHP runtime + * needed. Offline is a non-event: install silently waits for connectivity. Real failures roll + * back the partial tree, turn the task red (with a retry icon) and raise a toast. Works + * identically on Windows, macOS and Linux (no shell, no .bin shims). * * Layout under /lspServers/intelephense/: * package.json (written by us, exact version pin) @@ -45,36 +48,33 @@ define(function (require, exports, module) { const NodeUtils = brackets.getModule("utils/NodeUtils"), - ProjectManager = brackets.getModule("project/ProjectManager"), - NativeApp = brackets.getModule("utils/NativeApp"), - ModalBar = brackets.getModule("widgets/ModalBar").ModalBar, NotificationUI = brackets.getModule("widgets/NotificationUI"), - Dialogs = brackets.getModule("widgets/Dialogs"), - DefaultDialogs = brackets.getModule("widgets/DefaultDialogs"), TaskManager = brackets.getModule("features/TaskManager"), PreferencesManager = brackets.getModule("preferences/PreferencesManager"), + Metrics = brackets.getModule("utils/Metrics"), StringUtils = brackets.getModule("utils/StringUtils"), Strings = brackets.getModule("strings"); - const INTELEPHENSE_VERSION = "1.18.5"; + // Exact tested pin - never a range. The authoritative value lives in src/config.json + // (config.lsp_server_pins) so the scheduled update-lsp-pins workflow can bump it per + // release; the literal here is only a fallback for exotic boot states. + const _PINS = (brackets.config && brackets.config.lsp_server_pins) || {}; + if (!_PINS.intelephense) { + // appConfig.js is generated from src/config.json by the build - a missing pins block + // means a stale/broken build; scream so a developer can't miss it. The literal + // fallback below keeps things limping along. + window.alert("[PHPSupport] lsp_server_pins missing from AppConfig - " + + "stale build? Run npm run build."); + } + const INTELEPHENSE_VERSION = _PINS.intelephense || "1.18.5"; const PREF_PHP_CODE_INTELLIGENCE = "php.codeIntelligence"; - const INTELEPHENSE_HOME_URL = "https://intelephense.com"; - - // once-per-lifetime: the first time the install bar shows, a dialog is raised over it so the - // offer cannot be missed. Never shown again (even on cancel) - the bar remains the - // ongoing affordance. - const STATE_INSTALL_DIALOG_SHOWN = "php.installDialogShown"; - let _onInstalled = null; // main.js callback: ({entryPath, upgraded}) => void let _inFlight = null; // single-flight install promise - // projects where the user clicked "Not Now" - the bar stops reappearing there for the - // session. Until then it returns on every php file switch (closing on switch-away is not a - // dismissal - only the explicit button is). - const _promptDismissedForProject = new Set(); - let _promptBar = null; // the ModalBar install prompt, when showing - let _promptBarTip = null; // the benefits tooltip binding on the bar's info icon - let _panelRowDismissed = false; // Problems-panel row dismissed this session + let _cancelledThisSession = false; // user hit the task's stop icon - no retry until relaunch + let _cancelRequested = false; // stop clicked while an install is in flight + let _activeNpmInstall = null; // cancellable handle of the in-flight npm install + let _onlineRetryArmed = false; // one-shot window "online" retry listener armed function _installDirVfs() { return Phoenix.VFS.getAppSupportDir() + "lspServers/intelephense/"; @@ -135,9 +135,12 @@ define(function (require, exports, module) { await Phoenix.VFS.writeFileAsync(_packageJsonVfs(), JSON.stringify(pkg, null, 4), "utf8"); } - // Upgrade: wipe the old tree first - a stale package-lock.json vs rewritten package.json makes - // the node-side `npm ci` branch fail on manifest mismatch. - async function _wipeForUpgrade() { + // Wipe the npm artifacts (node_modules + lockfile) but keep cache/ (the index cache) and let + // package.json be rewritten. Used before every install (a stale package-lock.json vs the + // rewritten package.json makes the node-side `npm ci` branch fail on manifest mismatch, and + // a half-written node_modules from an interrupted run must not survive) and as the rollback + // after a failed/cancelled install. + async function _wipeTree() { try { await Phoenix.VFS.unlinkAsync(_installDirVfs() + "node_modules"); } catch (e) { @@ -150,29 +153,87 @@ define(function (require, exports, module) { } } + // Errors that mean "the network is unavailable", not "the install is broken". These take the + // QUIET path: offline must be a non-event, not a recurring red failure on every launch. + const NETWORK_ERROR_RE = new RegExp( + "failed to fetch|fetch failed|load failed|ENOTFOUND|ETIMEDOUT|ECONNRESET|ECONNREFUSED|" + + "EAI_AGAIN|ENETUNREACH|getaddrinfo|network", "i"); + function _isNetworkError(err) { + return NETWORK_ERROR_RE.test((err && err.message) || String(err)); + } + + // One-shot: retry the auto-install as soon as connectivity returns in this session. + function _armOnlineRetry() { + if (_onlineRetryArmed) { + return; + } + _onlineRetryArmed = true; + window.addEventListener("online", function () { + _onlineRetryArmed = false; + autoInstall(); + }, { once: true }); + } + + // Real-failure surface (in addition to the red task): the user learns setup failed even with + // the Tasks popup closed. + function _showFailureToast(message) { + const $tpl = $("
").text(message); + NotificationUI.createToastFromTemplate(Strings.PHP_INSTALL_TITLE, $tpl, { + dismissOnClick: true, + toastStyle: NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.SUBTLE, + autoCloseTimeS: 30, + instantOpen: true + }); + } + async function _doInstall() { const state = await installedState(); if (state.installed && state.pinMatches) { return { entryPath: getEntryPlatformPath(), upgraded: false }; } const upgrading = state.installed; - hidePanelRow(); - // Status-bar task: visible progress the user can track from anywhere in the app. npm gives - // no byte-level progress through execFile, so this advances by phase. + _cancelRequested = false; + // Status-bar task whose popup is OPENED for every install - with no consent dialog + // anymore, visibility is the transparency: the user must be able to see any download we + // start. npm gives no byte-level progress through execFile, so this advances by phase. const task = TaskManager.addNewTask(Strings.PHP_INSTALL_TITLE, Strings.PHP_INSTALLING, - "", { progressPercent: 5 }); + "", { + progressPercent: 5, + onStopClick: function () { + _cancelledThisSession = true; // no auto-retry until the next app launch + _cancelRequested = true; + if (_activeNpmInstall) { + _activeNpmInstall.cancel().catch(function () {}); + } + }, + onRetryClick: function () { + task.close(); + installNow(); + } + }); + task.showStopIcon(Strings.PHP_INSTALL_STOP); task.show(); try { - if (upgrading) { - await _wipeForUpgrade(); - } + // wipe-first: half-written trees from interrupted runs (and old versions on upgrade) + // must not confuse npm or the entry check + await _wipeTree(); task.setProgressPercent(15); await Phoenix.VFS.ensureExistsDirAsync(_installDirVfs()); await Phoenix.VFS.ensureExistsDirAsync(_installDirVfs() + "cache/"); await _writePinnedPackageJson(); task.setProgressPercent(25); + if (_cancelRequested) { + const cancelErr = new Error("install cancelled"); + cancelErr.cancelled = true; + throw cancelErr; + } const platformDir = Phoenix.fs.getTauriPlatformPath(_installDirVfs()); - await NodeUtils._npmInstallInFolder(platformDir); + _activeNpmInstall = NodeUtils._npmInstallInFolder(platformDir); + try { + await _activeNpmInstall; + } finally { + _activeNpmInstall = null; + } task.setProgressPercent(90); const ok = await Phoenix.VFS.existsAsync(_entryVfs()); if (!ok) { @@ -182,13 +243,40 @@ define(function (require, exports, module) { task.setMessage(Strings.PHP_INSTALL_DONE); task.setSucceded(); // (sic - TaskManager's exported name) setTimeout(task.close, 4000); + Metrics.countEvent("lsp", "phpInst", upgrading ? "upOk" : "ok"); return { entryPath: getEntryPlatformPath(), upgraded: upgrading }; } catch (err) { const message = (err && err.message) || String(err); + // rollback: a half-installed tree must not survive (the entry check keys "installed") + await _wipeTree(); + const cancelled = _cancelRequested || (err && err.cancelled) || /cancelled/i.test(message); + if (cancelled) { + // user's own decision - no red state, no toast + Metrics.countEvent("lsp", "phpInst", "cancel"); + task.close(); + return null; + } + if (_isNetworkError(err) || !navigator.onLine) { + // QUIET path: offline is normal life, not an error. Retry when connectivity + // returns (same session) or on the next launch. + Metrics.countEvent("lsp", "phpInst", "waitNet"); + task.setMessage(Strings.PHP_INSTALL_WAITING_NETWORK); + setTimeout(task.close, 4000); + _armOnlineRetry(); + return null; + } + console.error("[PHPSupport] install failed", err); + Metrics.countEvent("lsp", "phpInst", "fail"); + window.logger && window.logger.reportError(err, "[PHPSupport] LSP install failed"); task.setFailed(); task.setMessage(StringUtils.format(Strings.PHP_INSTALL_FAILED, message)); - setTimeout(task.close, 10000); + task.showRestartIcon(); + task.show(); + _showFailureToast(StringUtils.format(Strings.PHP_INSTALL_FAILED, message)); + setTimeout(task.close, 30000); return null; + } finally { + _activeNpmInstall = null; } } @@ -212,219 +300,40 @@ define(function (require, exports, module) { return _inFlight; } - // ----- consent UI: prompt toast + Problems-panel row (mirrors the TS enable affordances) ------ - - function _closePromptBar() { - if (_promptBarTip) { - _promptBarTip.detach(); - _promptBarTip = null; - } - if (_promptBar) { - _promptBar.close(); - _promptBar = null; - } - } - - function _benefitRows() { - return [ - [Strings.PHP_BENEFIT_COMPLETIONS, Strings.PHP_BENEFIT_COMPLETIONS_SUB], - [Strings.PHP_BENEFIT_DOCS, Strings.PHP_BENEFIT_DOCS_SUB], - [Strings.PHP_BENEFIT_ERRORS, Strings.PHP_BENEFIT_ERRORS_SUB], - [Strings.PHP_BENEFIT_NAV, Strings.PHP_BENEFIT_NAV_SUB] - ]; - } - - // Compact benefits card for the bar's (i) icon - term -> what it means, one line each. - function _benefitsTipHtml() { - const $tip = $("
"); - $("
").text(Strings.PHP_INSTALL_TITLE).appendTo($tip); - const $rows = $("
").appendTo($tip); - _benefitRows().forEach(function (row) { - $("").text(row[0]).appendTo($rows); - $("").text(row[1]).appendTo($rows); - }); - return $tip.html(); - } - - // The very first offer ever also raises a dialog over the bar - the bar alone is easy to - // miss. One shot per install (lifetime): cancelling leaves only the bar from then on. - // Deliberately terse: one line + an (i) whose hover card lists what it provides (same - // rich tooltip as the bar's info icon). - function _maybeShowFirstTimeDialog() { - const stateManager = PreferencesManager.stateManager; - if (stateManager.get(STATE_INSTALL_DIALOG_SHOWN)) { - return; - } - stateManager.set(STATE_INSTALL_DIALOG_SHOWN, true); - const $body = $("
"); - const $text = $("

").appendTo($body); - $("").text(Strings.PHP_INSTALL_DIALOG_TEXT).appendTo($text); - $text.append(" "); - $("").appendTo($text); - $("

").text(Strings.PHP_INSTALL_LATER_INFO).appendTo($body); - // quiet colophon crediting (and linking) the server this rides on - const $credit = $("

").appendTo($body); - $("").text(Strings.LSP_INSTALL_POWERED_BY + " ").appendTo($credit); - $("").text("Intelephense").appendTo($credit); - const dialog = Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_INFO, Strings.PHP_INSTALL_DIALOG_TITLE, - $body.html(), [ - { - className: Dialogs.DIALOG_BTN_CLASS_NORMAL, - id: Dialogs.DIALOG_BTN_CANCEL, - text: Strings.PHP_INSTALL_NOT_NOW - }, - { - className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, - id: Dialogs.DIALOG_BTN_OK, - text: Strings.PHP_INSTALL_ENABLE - } - ]); - const benefitsTip = NotificationUI.attachRichTooltip( - dialog.getElement().find(".lsp-install-dialog-info"), _benefitsTipHtml(), { showDelayMs: 150 }); - // the body went in as serialized HTML, so link handling is delegated on the live dialog - dialog.getElement().on("click", "a[data-href]", function (e) { - e.preventDefault(); - NativeApp.openURLInDefaultBrowser($(e.currentTarget).attr("data-href")); - }); - // the download size sits at the decision point - in the footer, left of the buttons - - // so the cost is read exactly when the user weighs Install (store-style metadata chip) - const $size = $(""); - $("").appendTo($size); - $size.append(document.createTextNode(" " + Strings.PHP_INSTALL_DIALOG_SIZE)); - $size.prependTo(dialog.getElement().find(".modal-footer")); - dialog.done(function (id) { - benefitsTip.detach(); - if (id === Dialogs.DIALOG_BTN_OK) { - _closePromptBar(); - installNow(); - } - // on cancel the bar stays - it is the ongoing affordance - }); - } - - // A find-bar-style banner across the top of the editor - impossible to miss on the file that - // triggered it, but passive (autoClose false: clicking back into the code doesn't dismiss it). - function _showPromptBar() { - const root = ProjectManager.getProjectRoot(); - const rootPath = (root && root.fullPath) || ""; - if (_promptDismissedForProject.has(rootPath) || _promptBar) { - return; - } - // built as detached DOM then serialized (ModalBar takes an HTML string); all click - // handling is delegated on the live bar root below - const $tpl = $("

"); - $("").text(Strings.PHP_INSTALL_MESSAGE).appendTo($tpl); - $("").appendTo($tpl); - // credit where due (and where premium lives) - not license-required, just right - $("").text(Strings.PHP_POWERED_BY_INTELEPHENSE) - .appendTo($tpl); - $("