Compare commits

..
Author SHA1 Message Date
l0ng-ai 0b4d53f2d8 fix(tools): install GitHub releases when the API is rate-limited
Language servers and debuggers published as GitHub releases were looked
up through api.github.com, which allows 60 unauthenticated requests an
hour per IP address. Behind a shared address the limit is often used up
and installs failed with 403. When the API request fails, the release is
now resolved from its web pages instead: the latest-release redirect gives
the tag, the asset list gives the files and their SHA-256 digests, and the
release notes give linked builds. If that fails too, the error names the
release page to download from and the setting to point at the file.

Fixes #1651
2026-11-06 13:23:13 +08:00
20 changed files with 57 additions and 366 deletions

No files matched your search

-3
View File
@@ -268,8 +268,6 @@ pub struct GeneralSettings {
pub power_save_mode: bool,
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
}
impl Default for GeneralSettings {
@@ -284,7 +282,6 @@ impl Default for GeneralSettings {
date_format: DateFormatSettings::default(),
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
}
}
}
+1 -27
View File
@@ -196,18 +196,10 @@ fn managed_dirs(root: &Path) -> Vec<PathBuf> {
dirs
}
/// The `PATH` an adapter runs with, unless its launch sets one: the search path language servers
/// get, so what it starts in turn (js-debug's wrapper runs `node`, Delve runs `go build`) is
/// found like Run finds it, even when the login shell's `PATH` didn't reach the app.
fn adapter_path(env: &[(String, String)]) -> Option<std::ffi::OsString> {
let own = env.iter().any(|(k, _)| if cfg!(windows) { k.eq_ignore_ascii_case("PATH") } else { k == "PATH" });
(!own).then(ide_core::tool_path::search_path)
}
/// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots.
fn search_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default();
dirs.extend(std::env::split_paths(&ide_core::tool_path::search_path()));
dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default());
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"]
{
@@ -472,9 +464,6 @@ impl AdapterLauncher for ProcessLauncher {
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
@@ -603,21 +592,6 @@ async fn connect_with_retries(
mod tests {
use super::*;
#[test]
fn adapters_run_on_the_tool_search_path() {
let search = ide_core::tool_path::search_path();
assert_eq!(adapter_path(&[]), Some(search.clone()));
assert_eq!(adapter_path(&[("GOFLAGS".into(), "-mod=mod".into())]), Some(search));
// A launch that sets its own (a selected Go SDK's bin first) keeps it.
assert_eq!(adapter_path(&[("PATH".into(), "/sdk/go/bin:/usr/bin".into())]), None);
// The search path reaches the usual install spots even when this process's `PATH` doesn't.
#[cfg(unix)]
{
let dirs: Vec<PathBuf> = std::env::split_paths(&ide_core::tool_path::search_path()).collect();
assert!(dirs.contains(&PathBuf::from("/usr/local/bin")), "{dirs:?}");
}
}
#[tokio::test]
async fn program_output_is_forwarded_without_the_adapter_banner() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+2 -3
View File
@@ -69,8 +69,7 @@ test("⌘K commits the checked changes with the typed message", async ({ workben
await expect(page.getByText(/1 file committed: Return order DTOs from create/)).toBeVisible();
const [committed] = await ipcCalls(page, "vcs_commit");
expect(committed).toMatchObject({ repo: ROOT, message: "Return order DTOs from create" });
// The commit takes the checked file itself; the index isn't staged beforehand.
expect((committed!.options as { stage: string[] }).stage).toEqual([`${ROOT}/build.gradle.kts`]);
expect(await ipcCalls(page, "vcs_stage")).toHaveLength(0);
const staged = (await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]);
expect(staged).toEqual([`${ROOT}/build.gradle.kts`]);
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
});
+10 -6
View File
@@ -8,11 +8,12 @@ import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
const NAMES = Array.from({ length: 20 }, (_, i) => `src/f${i}.ts`);
/** Twenty modified tracked files; `vcs_commit` takes the files it commits out of the status. */
/** Twenty modified tracked files; `vcs_stage` + `vcs_commit` take the staged ones out of the status. */
async function stubTwentyChanges(page: Page) {
await stubDesktop(page);
await page.evaluate(
async ({ root, names }) => {
const staged = new Set<string>();
const committed = new Set<string>();
const all = names.map((rel) => ({
path: `${root}/${rel}`,
@@ -32,10 +33,14 @@ async function stubTwentyChanges(page: Page) {
operation: null,
});
h.vcs_status = () => all.filter((f) => !committed.has(f.path));
h.vcs_stage = () => null;
h.vcs_stage = (args) => {
for (const path of args.paths as string[]) staged.add(path);
return null;
};
h.vcs_unstage = () => null;
h.vcs_commit = (args) => {
for (const path of (args.options as { stage: string[] }).stage) committed.add(path);
h.vcs_commit = () => {
for (const path of staged) committed.add(path);
staged.clear();
return "2222222";
};
const vcsModule = "/src/features/vcs/store.ts";
@@ -66,8 +71,7 @@ test("changes left out of a commit stay unchecked (#1539)", async ({ workbench:
await panel.getByRole("textbox", { name: "Commit message" }).fill("Commit 5 of 20");
await panel.getByRole("button", { name: "Commit", exact: true }).click();
await expect(page.getByText(/5 files committed: Commit 5 of 20/)).toBeVisible();
const [commit] = await ipcCalls(page, "vcs_commit");
expect([...(commit!.options as { stage: string[] }).stage].sort()).toEqual(
expect((await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]).sort()).toEqual(
NAMES.slice(0, 5)
.map((rel) => `${ROOT}/${rel}`)
.sort(),
+17 -65
View File
@@ -330,74 +330,36 @@ fn update_endpoint_env() -> Option<String> {
/// tauri.conf.json, or the `OPENIDEA_UPDATE_ENDPOINT` environment variable. Empty by
/// default, and "Check for Updates…" is hidden then.
pub fn updates_configured<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
!config_endpoints(app).is_empty() || update_endpoint_env().is_some()
}
/// Where updates are downloaded from (System Settings › Updates).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum UpdateSource {
/// The GitHub releases named in tauri.conf.json.
#[default]
Github,
/// The CNB mirror of the same releases, for networks where GitHub is slow.
Cnb,
}
const CNB_ENDPOINT: &str = "https://cnb.cool/l0ng-ai/openidea/-/releases/latest/download/latest.json";
fn config_endpoints<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
app.config()
let in_config = app
.config()
.plugins
.0
.get("updater")
.and_then(|u| u.get("endpoints"))
.and_then(|e| e.as_array())
.map(|e| e.iter().filter_map(|u| u.as_str().map(String::from)).collect())
.unwrap_or_default()
.is_some_and(|e| !e.is_empty());
in_config || update_endpoint_env().is_some()
}
/// The endpoints tried in order: `source` first, the other source when it fails or has
/// no manifest. Bundles are signed, so either source's manifest and downloads verify.
/// No official endpoint (a build without updates) means no mirror either.
fn source_endpoints(official: Vec<String>, source: UpdateSource) -> Vec<String> {
if official.is_empty() {
return official;
}
let mirror = vec![CNB_ENDPOINT.to_string()];
match source {
UpdateSource::Github => [official, mirror].concat(),
UpdateSource::Cnb => [mirror, official].concat(),
}
}
fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
fn updater(app: &AppHandle) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
use tauri_plugin_updater::UpdaterExt;
if !updates_configured(app) {
return Ok(None);
}
let endpoints = match update_endpoint_env() {
Some(url) => vec![url],
None => source_endpoints(config_endpoints(app), source),
};
let endpoints = endpoints
.iter()
.map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}"))))
.collect::<IdeResult<Vec<_>>>()?;
let env = update_endpoint_env();
let proxy = ide_core::proxy_settings::proxy();
app.updater_builder()
.configure_client(move |b| ide_http::client::with_proxy(b, &proxy))
.endpoints(endpoints)
.map_err(|e| IdeError::invalid(e.to_string()))?
.build()
.map(Some)
.map_err(|e| IdeError::unavailable(e.to_string()))
let mut builder = app.updater_builder().configure_client(move |b| ide_http::client::with_proxy(b, &proxy));
if let Some(url) = env {
let url = url.parse().map_err(|e| IdeError::invalid(format!("OPENIDEA_UPDATE_ENDPOINT: {e}")))?;
builder = builder.endpoints(vec![url]).map_err(|e| IdeError::invalid(e.to_string()))?;
}
builder.build().map(Some).map_err(|e| IdeError::unavailable(e.to_string()))
}
#[tauri::command]
#[specta::specta]
pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult<UpdateCheck> {
let Some(updater) = updater(&app, source)? else {
pub async fn app_check_update(app: AppHandle) -> IdeResult<UpdateCheck> {
let Some(updater) = updater(&app)? else {
return Ok(UpdateCheck { enabled: false, update: None });
};
let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?;
@@ -417,12 +379,12 @@ pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult
/// not reported: the user didn't ask for this check.
#[tauri::command]
#[specta::specta]
pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option<UpdateInfo> {
pub async fn app_startup_update_check(app: AppHandle) -> Option<UpdateInfo> {
static DONE: AtomicBool = AtomicBool::new(false);
if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) {
return None;
}
match app_check_update(app, source).await {
match app_check_update(app).await {
Ok(check) => check.update,
Err(e) => {
tracing::warn!("startup update check failed: {e}");
@@ -490,7 +452,6 @@ impl Drop for InstallingGuard {
pub async fn app_install_update(
app: AppHandle,
state: State<'_, AppState>,
source: UpdateSource,
request: RequestId,
on_progress: Channel<UpdateProgress>,
) -> IdeResult<()> {
@@ -498,7 +459,7 @@ pub async fn app_install_update(
InstallingGuard::acquire().ok_or_else(|| IdeError::unavailable("an update is already being installed"))?;
let token = state.host.begin_request(request);
let result = async {
let updater = updater(&app, source)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
let updater = updater(&app)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
let update = tokio::select! {
r = updater.check() => r.map_err(|e| IdeError::unavailable(e.to_string()))?,
_ = token.cancelled() => return Err(IdeError::cancelled()),
@@ -561,15 +522,6 @@ mod tests {
assert!(ProgressThrottle::default().chunk(1, Some(0)).is_some());
}
#[test]
fn chosen_source_is_tried_first_and_the_other_after_it() {
let github = "https://github.com/l0ng-ai/openidea/releases/latest/download/latest.json".to_string();
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Github), [github.clone(), CNB_ENDPOINT.into()]);
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb), [CNB_ENDPOINT.into(), github]);
// A build without an update endpoint stays without one.
assert!(source_endpoints(Vec::new(), UpdateSource::Cnb).is_empty());
}
#[test]
fn only_one_install_at_a_time() {
let first = InstallingGuard::acquire().expect("first install starts");
+4 -13
View File
@@ -910,24 +910,24 @@ export const commands = {
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update", { source })),
appCheckUpdate: () => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update")),
/**
* The background check made once per process when the first window starts;
* later calls (other windows) and debug builds get `None`. Failures are logged,
* not reported: the user didn't ask for this check.
*/
appStartupUpdateCheck: (source: UpdateSource) => __TAURI_INVOKE<{
appStartupUpdateCheck: () => __TAURI_INVOKE<{
version: string,
currentVersion: string,
notes: string | null,
date: string | null,
} | null>("app_startup_update_check", { source }),
} | null>("app_startup_update_check"),
/**
* Downloads and installs the available update, streaming progress to `on_progress`.
* Cancellable through `request` until the download completes; the UI then restarts
* with `app_restart`. One install runs at a time.
*/
appInstallUpdate: (source: UpdateSource, request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { source, request, onProgress })),
appInstallUpdate: (request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { request, onProgress })),
debugExceptionFilters: () => typedError<SessionExceptionFilters[], IdeError>(__TAURI_INVOKE("debug_exception_filters")),
debugSetExceptionBreakpoints: (choices: { [key in string]: boolean }) => typedError<null, IdeError>(__TAURI_INVOKE("debug_set_exception_breakpoints", { choices })),
debugSetVariable: (session: DebugSessionId, container: number, name: string, evaluateName: string | null, frameId: number | null, value: string) => typedError<Variable, IdeError>(__TAURI_INVOKE("debug_set_variable", { session, container, name, evaluateName, frameId, value })),
@@ -3866,8 +3866,6 @@ export type GeneralSettings = {
powerSaveMode?: boolean,
/** File › Rename Project… (project layer): the name shown for the project; empty = its folder name. */
projectName?: string,
/** Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. */
updateSource?: string,
};
/** A repository of the signed-in account (owned, collaborator, or organization member). */
@@ -7600,13 +7598,6 @@ export type UpdateProgress =
/** Download done; the new version is being written over this one. */
{ type: "installing" };
/** Where updates are downloaded from (System Settings › Updates). */
export type UpdateSource =
/** The GitHub releases named in tauri.conf.json. */
"github" |
/** The CNB mirror of the same releases, for networks where GitHub is slow. */
"cnb";
export type Variable = {
name: string,
value: string,
+7 -75
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Settings, UpdateCheck, UpdateProgress } from "@/bindings";
import type { UpdateCheck, UpdateProgress } from "@/bindings";
import type { NotificationService } from "@/workbench/contributions";
vi.mock("@tauri-apps/api/core", () => ({
@@ -20,7 +20,7 @@ function deferred<T>() {
let install: ReturnType<typeof deferred<Result<null>>>;
let check: ReturnType<typeof deferred<Result<UpdateCheck>>>;
let channel: Channel | null;
const appInstallUpdate = vi.fn((_source: string, _request: number, ch: Channel) => {
const appInstallUpdate = vi.fn((_request: number, ch: Channel) => {
channel = ch;
return install.promise;
});
@@ -28,35 +28,18 @@ const appCheckUpdate = vi.fn(() => check.promise);
const appRestart = vi.fn(async () => null);
const cancelRequest = vi.fn(async () => null);
const appBuildInfo = vi.fn(async () => ({ version: "0.1.21" }));
const settingsSet = vi.fn(async () => ({ status: "ok", data: { general: {}, appearance: {} } }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: {
...actual.commands,
appInstallUpdate,
appCheckUpdate,
appRestart,
cancelRequest,
appBuildInfo,
settingsSet,
},
commands: { ...actual.commands, appInstallUpdate, appCheckUpdate, appRestart, cancelRequest, appBuildInfo },
};
});
const {
checkForUpdates,
installUpdate,
notifyUpdate,
progressText,
updateSource,
CHECK_TASK_ID,
INSTALL_TASK_ID,
UPDATE_KEY,
} = await import("../updates");
const { checkForUpdates, installUpdate, notifyUpdate, progressText, CHECK_TASK_ID, INSTALL_TASK_ID, UPDATE_KEY } =
await import("../updates");
const { provideNotificationService } = await import("@/workbench/contributions");
const { useProject } = await import("@/platform/project");
const { useTasks } = await import("@/workbench/tasks");
@@ -77,7 +60,7 @@ beforeEach(() => {
check = deferred();
provideNotificationService({ notify: (n) => void notes.push(n), expire: (k) => void expired.push(k) });
useTasks.setState({ tasks: [] });
useProject.setState({ project: null, lightEdit: false, settings: null });
useProject.setState({ project: null, lightEdit: false });
});
afterEach(() => vi.clearAllMocks());
@@ -145,33 +128,12 @@ describe("Install and Restart", () => {
install = deferred();
const cancelled = installUpdate("0.1.22");
task(INSTALL_TASK_ID)!.cancel!();
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![1]);
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![0]);
install.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
await cancelled;
expect(notes.at(-1)).toMatchObject({ level: "info", title: "Update cancelled", key: UPDATE_KEY });
expect(appRestart).not.toHaveBeenCalled();
});
it.each([
["a failed", "unavailable", "github", "CNB", "cnb"],
["a failed", "unavailable", "cnb", "GitHub", "github"],
["a cancelled (likely slow)", "cancelled", "cnb", "GitHub", "github"],
])("offers %s download from %s again from the other source", async (_case, kind, from, label, to) => {
useProject.setState({ settings: { general: { updateSource: from }, appearance: {} } as unknown as Settings });
const first = installUpdate("0.1.22");
install.resolve({ status: "error", error: { kind, message: kind } });
await first;
const action = notes.at(-1)!.actions!.at(-1)!;
expect(action.label).toBe(`Retry with ${label}`);
install = deferred();
action.run();
await tick();
expect(settingsSet).toHaveBeenCalledWith("global", "general.updateSource", JSON.stringify(to));
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe(to);
install.resolve(ok(null));
await tick();
});
});
describe("Check for Updates", () => {
@@ -222,36 +184,6 @@ describe("Check for Updates", () => {
});
});
describe("updateSource", () => {
const withSettings = (updateSource: string | undefined, locale: string | undefined) =>
useProject.setState({ settings: { general: { updateSource }, appearance: { locale } } as unknown as Settings });
it.each([
["auto", "zh", "cnb"],
["auto", "zh-CN", "cnb"],
["auto", "en", "github"],
["auto", "ko", "github"],
[undefined, "zh", "cnb"],
["github", "zh", "github"],
["cnb", "en", "cnb"],
["bogus", "en", "github"],
])("%s with the %s UI uses %s", (setting, locale, expected) => {
withSettings(setting, locale);
expect(updateSource()).toBe(expected);
});
it("is passed to the check and the install", async () => {
withSettings("cnb", "en");
void checkForUpdates();
expect(appCheckUpdate).toHaveBeenCalledWith("cnb");
check.resolve(ok({ enabled: true, update: null }));
void installUpdate("0.1.22");
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe("cnb");
install.resolve(ok(null));
await tick();
});
});
describe("progressText", () => {
it("formats known and unknown lengths", () => {
expect(progressText({ type: "downloading", downloaded: 1024 * 1024, total: null })).toEqual({
+9 -51
View File
@@ -7,7 +7,7 @@
* repeated click joins the running check or install instead of starting another.
*/
import { Channel } from "@tauri-apps/api/core";
import { resolveLocale, t } from "@/platform/i18n";
import { t } from "@/platform/i18n";
import {
call,
commands,
@@ -16,7 +16,6 @@ import {
nextRequestId,
type UpdateInfo,
type UpdateProgress,
type UpdateSource,
} from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
@@ -31,39 +30,6 @@ function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
/** System Settings › Updates › Update source; `auto` picks the CNB mirror for the Chinese UI. */
export function updateSource(): UpdateSource {
const { settings } = useProject.getState();
const chosen = settings?.general?.updateSource;
if (chosen === "github" || chosen === "cnb") return chosen;
return resolveLocale(settings?.appearance?.locale) === "zh" ? "cnb" : "github";
}
const SOURCE_NAMES: Record<UpdateSource, string> = { github: "GitHub", cnb: "CNB" };
/** Makes `source` the update source; the retry it precedes uses it even if saving fails. */
async function chooseSource(source: UpdateSource) {
try {
useProject.setState({
settings: await call(commands.settingsSet("global", "general.updateSource", JSON.stringify(source))),
});
} catch {
/* the choice still applies to this retry */
}
}
/**
* Offered once a download failed or was cancelled (likely too slow): the same install from
* the other source. A check never needs it, since it already falls back to the other source.
*/
function otherSourceAction(version: string, source: UpdateSource) {
const other: UpdateSource = source === "cnb" ? "github" : "cnb";
return {
label: t("app.update.retryWith", { source: SOURCE_NAMES[other] }),
run: () => void chooseSource(other).then(() => installUpdate(version, other)),
};
}
let checking: Promise<void> | null = null;
let installing: Promise<void> | null = null;
@@ -90,7 +56,7 @@ async function runCheck() {
key: UPDATE_KEY,
});
try {
const r = await Promise.race([call(commands.appCheckUpdate(updateSource())), abandoned]);
const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]);
if (r === "cancelled") return;
if (!r.enabled) {
notifications().notify({
@@ -119,7 +85,7 @@ async function runCheck() {
/** Silent check once per app start; only an available update is shown. */
export async function checkForUpdatesOnStartup(): Promise<void> {
if (!isTauri()) return;
const update = await commands.appStartupUpdateCheck(updateSource()).catch(() => null);
const update = await commands.appStartupUpdateCheck().catch(() => null);
if (update) notifyUpdate(update);
}
@@ -152,13 +118,13 @@ export function progressText(p: UpdateProgress): { message: string; fraction: nu
}
/** Downloads and installs `version`, then restarts. A second call while one runs joins it. */
export function installUpdate(version: string, source = updateSource()): Promise<void> {
export function installUpdate(version: string): Promise<void> {
if (!isTauri()) return Promise.resolve();
installing ??= runInstall(version, source).finally(() => (installing = null));
installing ??= runInstall(version).finally(() => (installing = null));
return installing;
}
async function runInstall(version: string, source: UpdateSource) {
async function runInstall(version: string) {
const title = t("app.update.downloading", { version });
const request = nextRequestId();
const cancel = () => void commands.cancelRequest(request).catch(() => {});
@@ -194,25 +160,17 @@ async function runInstall(version: string, source: UpdateSource) {
// Replaces the notification whose action is now spent.
balloon([{ label: t("app.update.cancel"), run: cancel }]);
try {
await call(commands.appInstallUpdate(source, request, channel));
await call(commands.appInstallUpdate(request, channel));
} catch (e) {
if (isCancelled(e)) {
notifications().notify({
level: "info",
title: t("app.update.cancelled"),
key: UPDATE_KEY,
actions: [otherSourceAction(version, source)],
});
notifications().notify({ level: "info", title: t("app.update.cancelled"), key: UPDATE_KEY });
} else {
notifications().notify({
level: "error",
title: t("app.update.installFailed"),
body: errorText(e),
key: UPDATE_KEY,
actions: [
{ label: t("app.update.retry"), run: () => void installUpdate(version, source) },
otherSourceAction(version, source),
],
actions: [{ label: t("app.update.retry"), run: () => void installUpdate(version) }],
});
}
return;
@@ -50,11 +50,8 @@ export interface DateFormatValid {
pretty: boolean;
}
export type UpdateSourceSetting = "auto" | "github" | "cnb";
export interface GeneralExtras {
openProjectIn: OpenProjectIn;
updateSource: UpdateSourceSetting;
defaultProjectDirectory: string;
saveOnIdle: boolean;
idleSeconds: number;
@@ -103,7 +100,6 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = {
openProjectIn: "ask",
updateSource: "auto",
defaultProjectDirectory: "",
saveOnIdle: false,
idleSeconds: 15,
@@ -164,7 +160,6 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras {
const df = obj(g.dateFormat);
return {
openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn),
updateSource: oneOf(g.updateSource, ["auto", "github", "cnb"] as const, d.updateSource),
defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "",
saveOnIdle: bool(g.saveOnIdle, d.saveOnIdle),
idleSeconds: int(g.idleSeconds, d.idleSeconds, IDLE_LIMITS),
-19
View File
@@ -26,7 +26,6 @@ import {
type NotificationDisplay,
type NotificationGroupValid,
type OpenProjectIn,
type UpdateSourceSetting,
} from "../model/behaviorSchema";
import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema";
import type { PageSpec } from "./spec";
@@ -382,12 +381,6 @@ const OPEN_IN: Array<{ value: OpenProjectIn; label: string }> = [
{ value: "ask", label: "Ask" },
];
const UPDATE_SOURCES: SelectOption<UpdateSourceSetting>[] = [
{ value: "auto", label: "Automatic" },
{ value: "github", label: "GitHub" },
{ value: "cnb", label: "CNB (China mirror)" },
];
function OpenProjectInRow() {
const [value, set] = useSetting<OpenProjectIn>("general.openProjectIn");
const name = useId();
@@ -731,18 +724,6 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
},
],
},
{
title: "Updates",
options: [
{
kind: "select",
key: "general.updateSource",
label: "Update source",
hint: "Where updates are downloaded from. If it can't be reached, the other source is tried.",
options: UPDATE_SOURCES,
},
],
},
{
title: "Exit",
options: [{ kind: "toggle", key: "general.confirmExit", label: "Confirm before exiting the IDE" }],
+2 -3
View File
@@ -7,7 +7,6 @@
import { FontPicker } from "../components/FontPicker";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { quotePath } from "@/platform/commandLine";
import { commands, isTauri } from "@/platform/ipc";
import {
DEFAULT_EXTERNAL_TOOL,
@@ -111,7 +110,7 @@ function ShellPathRow() {
const { open: pick } = await import("@tauri-apps/plugin-dialog");
const picked = await pick({ multiple: false, title: "Shell Path" });
const path = Array.isArray(picked) ? picked[0] : picked;
if (path) set(quotePath(path));
if (path) set(path);
};
return (
<Row
@@ -143,7 +142,7 @@ function ShellPathRow() {
anchor={menuRef}
placement="bottom-end"
aria-label="Installed shells"
items={shells.map((p) => ({ id: p, label: p, checked: quotePath(p) === value, run: () => set(quotePath(p)) }))}
items={shells.map((p) => ({ id: p, label: p, checked: p === value, run: () => set(p) }))}
/>
<IconButton
icon="folder-open"
+3 -3
View File
@@ -16,9 +16,9 @@ import { installTerminalPaste, pasteTerminalClipboard } from "./paste";
import { ImeCommitGate } from "./imeCommit";
import { ownTerminalInput } from "./imeInput";
import { findTerminalLinks, resolvePath, type TerminalLink } from "./links";
import { parseShellCommand } from "@/platform/commandLine";
import { splitCommandLine } from "@/platform/commandLine";
import { useProject } from "@/platform/project";
import { hostOs, useRemote } from "@/platform/remote";
import { useRemote } from "@/platform/remote";
import { usePlatform } from "@/platform/platformInfo";
import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings";
import { editorPrefs } from "@/platform/editorSettings";
@@ -530,7 +530,7 @@ export class TerminalSession {
};
try {
const t = workflowSettings().terminal;
const [shellPath, ...shellArgs] = parseShellCommand(t.shellPath, hostOs() === "windows");
const [shellPath, ...shellArgs] = splitCommandLine(t.shellPath);
const shell = custom ? custom.program : shellPath;
const args = custom ? custom.args : shellArgs;
const info = await call(
-1
View File
@@ -11,7 +11,6 @@ export const CLIENT_SETTINGS_KEYS = [
"general.reopenLastProject",
"general.confirmExit",
"general.openProjectIn",
"general.updateSource",
"general.dateFormat",
"editor.fontFamily",
"editor.fontSize",
-54
View File
@@ -1,54 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseShellCommand, quotePath } from "./commandLine";
describe("parseShellCommand", () => {
it("keeps Windows paths as typed", () => {
expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe`, true)).toEqual([
String.raw`C:\Windows\system32\cmd.exe`,
]);
expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe /k C:\tools\init.bat`, true)).toEqual([
String.raw`C:\Windows\system32\cmd.exe`,
"/k",
String.raw`C:\tools\init.bat`,
]);
});
it("takes an unquoted Windows path with spaces up to its executable", () => {
expect(parseShellCommand(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`, true)).toEqual([
String.raw`C:\Program Files\PowerShell\7\pwsh.exe`,
]);
expect(parseShellCommand(String.raw`C:\Program Files\Git\bin\bash.EXE --login -i`, true)).toEqual([
String.raw`C:\Program Files\Git\bin\bash.EXE`,
"--login",
"-i",
]);
});
it("honours quotes", () => {
expect(parseShellCommand(String.raw`"C:\Program Files\Git\bin\bash.exe" --login`, true)).toEqual([
String.raw`C:\Program Files\Git\bin\bash.exe`,
"--login",
]);
expect(parseShellCommand(`/bin/zsh --login`, false)).toEqual(["/bin/zsh", "--login"]);
expect(parseShellCommand(`"/Applications/My Shell/bin/fish" -l`, false)).toEqual([
"/Applications/My Shell/bin/fish",
"-l",
]);
expect(parseShellCommand(String.raw`/bin/sh -c a\ b`, false)).toEqual(["/bin/sh", "-c", "a b"]);
});
it("is empty for an empty setting", () => {
expect(parseShellCommand(" ", true)).toEqual([]);
expect(parseShellCommand("", false)).toEqual([]);
});
});
describe("quotePath", () => {
it("quotes only paths with whitespace", () => {
expect(quotePath(String.raw`C:\Windows\system32\cmd.exe`)).toBe(String.raw`C:\Windows\system32\cmd.exe`);
expect(quotePath(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`)).toBe(
String.raw`"C:\Program Files\PowerShell\7\pwsh.exe"`,
);
expect(parseShellCommand(quotePath("/Applications/My Shell/fish"), false)).toEqual(["/Applications/My Shell/fish"]);
});
});
-22
View File
@@ -67,28 +67,6 @@ export function parseArgs(input: string, windowsPaths = false): string[] {
return out;
}
/**
* The program and arguments of a shell command line (Settings › Terminal › Shell path). On
* Windows a backslash is a path separator, and an unquoted path up to its `.exe` / `.cmd` /
* `.bat` / `.com` is the program even with spaces in it (`C:\Program Files\…\pwsh.exe -NoLogo`).
*/
export function parseShellCommand(line: string, windows: boolean): string[] {
const text = line.trim();
if (windows && !/["']/.test(text)) {
const end = /\.(?:exe|cmd|bat|com)(?=\s|$)/i.exec(text);
if (end) {
const stop = end.index + end[0].length;
return [text.slice(0, stop), ...parseArgs(text.slice(stop), true)];
}
}
return parseArgs(text, windows);
}
/** `path` as one word of a command line: quoted when it has whitespace. */
export function quotePath(path: string): string {
return /\s/.test(path) ? `"${path}"` : path;
}
/** Inverse of `parseArgs` (quotes only when needed). */
export function formatArgs(args: readonly string[]): string {
return args.map((a) => (a === "" ? "''" : /[\s'"\\$`]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a)).join(" ");
-1
View File
@@ -81,7 +81,6 @@
"app.update.progressUnknown": "{done} MB",
"app.update.restart": "Restart",
"app.update.retry": "Retry",
"app.update.retryWith": "Retry with {source}",
"app.window.desktopOnly": "Multiple windows require the desktop app",
"app.window.openFailed": "Cannot open {name}",
"app.window.pickFileTitle": "Open File",
+1 -7
View File
@@ -37,11 +37,5 @@
"e.g. /var/www/html": "e.g. /var/www/html",
"Listening for Xdebug on port {port}": "Listening for Xdebug on port {port}",
"Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.",
"Unable to load image: {path}": "Unable to load image: {path}",
"Updates": "Updates",
"Update source": "Update source",
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "Where updates are downloaded from. If it can't be reached, the other source is tried.",
"Automatic": "Automatic",
"CNB (China mirror)": "CNB (China mirror)",
"Remote connection cancelled": "Remote connection cancelled"
"Unable to load image: {path}": "Unable to load image: {path}"
}
+1 -6
View File
@@ -6377,10 +6377,5 @@
"Force push anyway": "仍然强制推送",
"Stage and Retry": "暂存并重试",
"Show Files": "显示文件",
"Unable to load image: {path}": "无法加载图片:{path}",
"Updates": "更新",
"Update source": "更新源",
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "从哪里下载更新。无法连接时会改用另一个源。",
"CNB (China mirror)": "CNB(国内镜像)",
"Remote connection cancelled": "远程连接已取消"
"Unable to load image: {path}": "无法加载图片:{path}"
}
-1
View File
@@ -94,7 +94,6 @@ const UNTRANSLATED = new Set<string>([
"oxfmt",
"Ruff",
"IntelliJ IDEA",
"GitHub",
"PHPStan",
// Docker run target placeholder values
"compose.yaml",
-1
View File
@@ -81,7 +81,6 @@
"app.update.progressUnknown": "{done} MB",
"app.update.restart": "重启",
"app.update.retry": "重试",
"app.update.retryWith": "改用 {source} 重试",
"app.window.desktopOnly": "多窗口需要桌面应用",
"app.window.openFailed": "无法打开 {name}",
"app.window.pickFileTitle": "打开文件",