mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
refactor: share browser and sandbox helpers
This commit is contained in:
@@ -6,7 +6,7 @@ import type {
|
||||
SandboxFsStat,
|
||||
SandboxResolvedPath,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import { resolveWritableRenameTargetsForBridge } from "openclaw/plugin-sdk/sandbox";
|
||||
import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox";
|
||||
import type { OpenShellSandboxBackend } from "./backend.js";
|
||||
import { movePathWithCopyFallback } from "./mirror.js";
|
||||
|
||||
@@ -24,19 +24,16 @@ export function createOpenShellFsBridge(params: {
|
||||
}
|
||||
|
||||
class OpenShellFsBridge implements SandboxFsBridge {
|
||||
private readonly resolveRenameTargets = createWritableRenameTargetResolver(
|
||||
(target) => this.resolveTarget(target),
|
||||
(target, action) => this.ensureWritable(target, action),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly sandbox: SandboxContext,
|
||||
private readonly backend: OpenShellSandboxBackend,
|
||||
) {}
|
||||
|
||||
private resolveRenameTargets(params: { from: string; to: string; cwd?: string }) {
|
||||
return resolveWritableRenameTargetsForBridge(
|
||||
params,
|
||||
(target) => this.resolveTarget(target),
|
||||
(target, action) => this.ensureWritable(target, action),
|
||||
);
|
||||
}
|
||||
|
||||
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
|
||||
const target = this.resolveTarget(params);
|
||||
return {
|
||||
|
||||
@@ -46,6 +46,7 @@ export {
|
||||
uploadDirectoryToSshTarget,
|
||||
} from "./sandbox/ssh.js";
|
||||
export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js";
|
||||
export { createWritableRenameTargetResolver } from "./sandbox/fs-bridge-rename-targets.js";
|
||||
export { resolveWritableRenameTargets } from "./sandbox/fs-bridge-rename-targets.js";
|
||||
export { resolveWritableRenameTargetsForBridge } from "./sandbox/fs-bridge-rename-targets.js";
|
||||
|
||||
|
||||
@@ -30,3 +30,10 @@ export function resolveWritableRenameTargetsForBridge<T extends { containerPath:
|
||||
ensureWritable,
|
||||
});
|
||||
}
|
||||
|
||||
export function createWritableRenameTargetResolver<T extends { containerPath: string }>(
|
||||
resolveTarget: (params: { filePath: string; cwd?: string }) => T,
|
||||
ensureWritable: (target: T, action: string) => void,
|
||||
): (params: { from: string; to: string; cwd?: string }) => { from: T; to: T } {
|
||||
return (params) => resolveWritableRenameTargetsForBridge(params, resolveTarget, ensureWritable);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "node:path";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js";
|
||||
import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js";
|
||||
import { resolveWritableRenameTargetsForBridge } from "./fs-bridge-rename-targets.js";
|
||||
import { createWritableRenameTargetResolver } from "./fs-bridge-rename-targets.js";
|
||||
import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js";
|
||||
import {
|
||||
isPathInsideContainerRoot,
|
||||
@@ -36,19 +36,16 @@ export function createRemoteShellSandboxFsBridge(params: {
|
||||
}
|
||||
|
||||
class RemoteShellSandboxFsBridge implements SandboxFsBridge {
|
||||
private readonly resolveRenameTargets = createWritableRenameTargetResolver(
|
||||
(target) => this.resolveTarget(target),
|
||||
(target, action) => this.ensureWritable(target, action),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly sandbox: SandboxContext,
|
||||
private readonly runtime: RemoteShellSandboxHandle,
|
||||
) {}
|
||||
|
||||
private resolveRenameTargets(params: { from: string; to: string; cwd?: string }) {
|
||||
return resolveWritableRenameTargetsForBridge(
|
||||
params,
|
||||
(target) => this.resolveTarget(target),
|
||||
(target, action) => this.ensureWritable(target, action),
|
||||
);
|
||||
}
|
||||
|
||||
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
|
||||
const target = this.resolveTarget(params);
|
||||
return {
|
||||
|
||||
@@ -11,12 +11,17 @@ import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
|
||||
import { isValidIPv4 } from "../gateway/net.js";
|
||||
import {
|
||||
detectBrowserOpenSupport,
|
||||
openUrl,
|
||||
openUrlInBackground,
|
||||
resolveBrowserOpenCommand,
|
||||
} from "../infra/browser-open.js";
|
||||
import { detectBinary } from "../infra/detect-binary.js";
|
||||
import {
|
||||
inspectBestEffortPrimaryTailnetIPv4,
|
||||
pickBestEffortPrimaryLanIPv4,
|
||||
} from "../infra/network-discovery-display.js";
|
||||
import { isWSL } from "../infra/wsl.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
@@ -26,6 +31,7 @@ import { VERSION } from "../version.js";
|
||||
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
|
||||
|
||||
export { detectBinary };
|
||||
export { detectBrowserOpenSupport, openUrl, openUrlInBackground, resolveBrowserOpenCommand };
|
||||
|
||||
export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T {
|
||||
if (isCancel(value)) {
|
||||
@@ -128,79 +134,6 @@ export function applyWizardMetadata(
|
||||
};
|
||||
}
|
||||
|
||||
type BrowserOpenSupport = {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
command?: string;
|
||||
};
|
||||
|
||||
type BrowserOpenCommand = {
|
||||
argv: string[] | null;
|
||||
reason?: string;
|
||||
command?: string;
|
||||
/**
|
||||
* Whether the URL must be wrapped in quotes when appended to argv.
|
||||
* Needed for Windows `cmd /c start` where `&` splits commands.
|
||||
*/
|
||||
quoteUrl?: boolean;
|
||||
};
|
||||
|
||||
export async function resolveBrowserOpenCommand(): Promise<BrowserOpenCommand> {
|
||||
const platform = process.platform;
|
||||
const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
||||
const isSsh =
|
||||
Boolean(process.env.SSH_CLIENT) ||
|
||||
Boolean(process.env.SSH_TTY) ||
|
||||
Boolean(process.env.SSH_CONNECTION);
|
||||
|
||||
if (isSsh && !hasDisplay && platform !== "win32") {
|
||||
return { argv: null, reason: "ssh-no-display" };
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
return {
|
||||
argv: ["cmd", "/c", "start", ""],
|
||||
command: "cmd",
|
||||
quoteUrl: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
const hasOpen = await detectBinary("open");
|
||||
return hasOpen ? { argv: ["open"], command: "open" } : { argv: null, reason: "missing-open" };
|
||||
}
|
||||
|
||||
if (platform === "linux") {
|
||||
const wsl = await isWSL();
|
||||
if (!hasDisplay && !wsl) {
|
||||
return { argv: null, reason: "no-display" };
|
||||
}
|
||||
if (wsl) {
|
||||
const hasWslview = await detectBinary("wslview");
|
||||
if (hasWslview) {
|
||||
return { argv: ["wslview"], command: "wslview" };
|
||||
}
|
||||
if (!hasDisplay) {
|
||||
return { argv: null, reason: "wsl-no-wslview" };
|
||||
}
|
||||
}
|
||||
const hasXdgOpen = await detectBinary("xdg-open");
|
||||
return hasXdgOpen
|
||||
? { argv: ["xdg-open"], command: "xdg-open" }
|
||||
: { argv: null, reason: "missing-xdg-open" };
|
||||
}
|
||||
|
||||
return { argv: null, reason: "unsupported-platform" };
|
||||
}
|
||||
|
||||
export async function detectBrowserOpenSupport(): Promise<BrowserOpenSupport> {
|
||||
const resolved = await resolveBrowserOpenCommand();
|
||||
if (!resolved.argv) {
|
||||
return { ok: false, reason: resolved.reason };
|
||||
}
|
||||
return { ok: true, command: resolved.command };
|
||||
}
|
||||
|
||||
export function formatControlUiSshHint(params: {
|
||||
port: number;
|
||||
basePath?: string;
|
||||
@@ -234,57 +167,6 @@ function resolveSshTargetHint(): string {
|
||||
return `${user}@${host}`;
|
||||
}
|
||||
|
||||
export async function openUrl(url: string): Promise<boolean> {
|
||||
if (shouldSkipBrowserOpenInTests()) {
|
||||
return false;
|
||||
}
|
||||
const resolved = await resolveBrowserOpenCommand();
|
||||
if (!resolved.argv) {
|
||||
return false;
|
||||
}
|
||||
const quoteUrl = resolved.quoteUrl === true;
|
||||
const command = [...resolved.argv];
|
||||
if (quoteUrl) {
|
||||
if (command.at(-1) === "") {
|
||||
// Preserve the empty title token for `start` when using verbatim args.
|
||||
command[command.length - 1] = '""';
|
||||
}
|
||||
command.push(`"${url}"`);
|
||||
} else {
|
||||
command.push(url);
|
||||
}
|
||||
try {
|
||||
await runCommandWithTimeout(command, {
|
||||
timeoutMs: 5_000,
|
||||
windowsVerbatimArguments: quoteUrl,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// ignore; we still print the URL for manual open
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openUrlInBackground(url: string): Promise<boolean> {
|
||||
if (shouldSkipBrowserOpenInTests()) {
|
||||
return false;
|
||||
}
|
||||
if (process.platform !== "darwin") {
|
||||
return false;
|
||||
}
|
||||
const resolved = await resolveBrowserOpenCommand();
|
||||
if (!resolved.argv || resolved.command !== "open") {
|
||||
return false;
|
||||
}
|
||||
const command = ["open", "-g", url];
|
||||
try {
|
||||
await runCommandWithTimeout(command, { timeoutMs: 5_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWorkspaceAndSessions(
|
||||
workspaceDir: string,
|
||||
runtime: RuntimeEnv,
|
||||
@@ -340,13 +222,6 @@ export async function handleReset(scope: ResetScope, workspaceDir: string, runti
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipBrowserOpenInTests(): boolean {
|
||||
if (process.env.VITEST) {
|
||||
return true;
|
||||
}
|
||||
return process.env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
export async function probeGatewayReachable(params: {
|
||||
url: string;
|
||||
token?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { VERSION } from "../version.js";
|
||||
import type { ConfigUiHints } from "./schema.hints.js";
|
||||
import { buildBaseHints, mapSensitivePaths } from "./schema.hints.js";
|
||||
import { asSchemaObject, cloneSchema } from "./schema.shared.js";
|
||||
import { applyDerivedTags } from "./schema.tags.js";
|
||||
import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
@@ -12,6 +13,9 @@ type JsonSchemaObject = Record<string, unknown> & {
|
||||
additionalProperties?: JsonSchemaObject | boolean;
|
||||
};
|
||||
|
||||
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
|
||||
asSchemaObject<JsonSchemaObject>(value);
|
||||
|
||||
export type BaseConfigSchemaResponse = {
|
||||
schema: ConfigSchema;
|
||||
uiHints: ConfigUiHints;
|
||||
@@ -21,23 +25,9 @@ export type BaseConfigSchemaResponse = {
|
||||
|
||||
type BaseConfigSchemaStablePayload = Omit<BaseConfigSchemaResponse, "generatedAt">;
|
||||
|
||||
function cloneSchema<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function asSchemaObject(value: unknown): JsonSchemaObject | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as JsonSchemaObject;
|
||||
}
|
||||
|
||||
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
const root = asJsonSchemaObject(next);
|
||||
if (!root || !root.properties) {
|
||||
return next;
|
||||
}
|
||||
@@ -47,7 +37,7 @@ function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
|
||||
if (Array.isArray(root.required)) {
|
||||
root.required = root.required.filter((key) => key !== "$schema");
|
||||
}
|
||||
const channelsNode = asSchemaObject(root.properties.channels);
|
||||
const channelsNode = asJsonSchemaObject(root.properties.channels);
|
||||
if (channelsNode) {
|
||||
channelsNode.properties = {};
|
||||
channelsNode.required = [];
|
||||
|
||||
@@ -8,6 +8,20 @@ type JsonSchemaObject = {
|
||||
oneOf?: JsonSchemaObject[];
|
||||
};
|
||||
|
||||
export function cloneSchema<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function asSchemaObject<T extends object>(value: unknown): T | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function schemaHasChildren(schema: JsonSchemaObject): boolean {
|
||||
if (schema.properties && Object.keys(schema.properties).length > 0) {
|
||||
return true;
|
||||
|
||||
@@ -3,7 +3,12 @@ import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import { GENERATED_BASE_CONFIG_SCHEMA } from "./schema.base.generated.js";
|
||||
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||
import { applySensitiveHints } from "./schema.hints.js";
|
||||
import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js";
|
||||
import {
|
||||
asSchemaObject,
|
||||
cloneSchema,
|
||||
findWildcardHintMatch,
|
||||
schemaHasChildren,
|
||||
} from "./schema.shared.js";
|
||||
import { applyDerivedTags } from "./schema.tags.js";
|
||||
|
||||
export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||
@@ -20,6 +25,9 @@ type JsonSchemaObject = JsonSchemaNode & {
|
||||
items?: JsonSchemaObject | JsonSchemaObject[];
|
||||
};
|
||||
|
||||
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
|
||||
asSchemaObject<JsonSchemaObject>(value);
|
||||
|
||||
const FORBIDDEN_LOOKUP_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
const LOOKUP_SCHEMA_STRING_KEYS = new Set([
|
||||
"$id",
|
||||
@@ -53,20 +61,6 @@ const LOOKUP_SCHEMA_BOOLEAN_KEYS = new Set([
|
||||
]);
|
||||
const MAX_LOOKUP_PATH_SEGMENTS = 32;
|
||||
|
||||
function cloneSchema<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function asSchemaObject(value: unknown): JsonSchemaObject | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as JsonSchemaObject;
|
||||
}
|
||||
|
||||
function isObjectSchema(schema: JsonSchemaObject): boolean {
|
||||
const type = schema.type;
|
||||
if (type === "object") {
|
||||
@@ -284,14 +278,14 @@ function applyHeartbeatTargetHints(
|
||||
|
||||
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
const pluginsNode = asSchemaObject(root?.properties?.plugins);
|
||||
const entriesNode = asSchemaObject(pluginsNode?.properties?.entries);
|
||||
const root = asJsonSchemaObject(next);
|
||||
const pluginsNode = asJsonSchemaObject(root?.properties?.plugins);
|
||||
const entriesNode = asJsonSchemaObject(pluginsNode?.properties?.entries);
|
||||
if (!entriesNode) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const entryBase = asSchemaObject(entriesNode.additionalProperties);
|
||||
const entryBase = asJsonSchemaObject(entriesNode.additionalProperties);
|
||||
const entryProperties = entriesNode.properties ?? {};
|
||||
entriesNode.properties = entryProperties;
|
||||
|
||||
@@ -302,9 +296,9 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
|
||||
const entrySchema = entryBase
|
||||
? cloneSchema(entryBase)
|
||||
: ({ type: "object" } as JsonSchemaObject);
|
||||
const entryObject = asSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
|
||||
const baseConfigSchema = asSchemaObject(entryObject.properties?.config);
|
||||
const pluginSchema = asSchemaObject(plugin.configSchema);
|
||||
const entryObject = asJsonSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
|
||||
const baseConfigSchema = asJsonSchemaObject(entryObject.properties?.config);
|
||||
const pluginSchema = asJsonSchemaObject(plugin.configSchema);
|
||||
const nextConfigSchema =
|
||||
baseConfigSchema &&
|
||||
pluginSchema &&
|
||||
@@ -325,8 +319,8 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
|
||||
|
||||
function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
const channelsNode = asSchemaObject(root?.properties?.channels);
|
||||
const root = asJsonSchemaObject(next);
|
||||
const channelsNode = asJsonSchemaObject(root?.properties?.channels);
|
||||
if (!channelsNode) {
|
||||
return next;
|
||||
}
|
||||
@@ -337,8 +331,8 @@ function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]
|
||||
if (!channel.configSchema) {
|
||||
continue;
|
||||
}
|
||||
const existing = asSchemaObject(channelProps[channel.id]);
|
||||
const incoming = asSchemaObject(channel.configSchema);
|
||||
const existing = asJsonSchemaObject(channelProps[channel.id]);
|
||||
const incoming = asJsonSchemaObject(channel.configSchema);
|
||||
if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) {
|
||||
channelProps[channel.id] = mergeObjectSchema(existing, incoming);
|
||||
} else {
|
||||
@@ -502,7 +496,7 @@ function resolveLookupChildSchema(
|
||||
|
||||
const properties = schema.properties;
|
||||
if (properties && Object.hasOwn(properties, segment)) {
|
||||
return asSchemaObject(properties[segment]);
|
||||
return asJsonSchemaObject(properties[segment]);
|
||||
}
|
||||
|
||||
const itemIndex = /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : undefined;
|
||||
@@ -621,7 +615,7 @@ export function lookupConfigSchema(
|
||||
return null;
|
||||
}
|
||||
|
||||
let current = asSchemaObject(response.schema);
|
||||
let current = asJsonSchemaObject(response.schema);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
127
src/infra/browser-open.ts
Normal file
127
src/infra/browser-open.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { detectBinary } from "./detect-binary.js";
|
||||
import { isWSL } from "./wsl.js";
|
||||
|
||||
export type BrowserOpenCommand = {
|
||||
argv: string[] | null;
|
||||
reason?: string;
|
||||
command?: string;
|
||||
quoteUrl?: boolean;
|
||||
};
|
||||
|
||||
export type BrowserOpenSupport = {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
command?: string;
|
||||
};
|
||||
|
||||
function shouldSkipBrowserOpenInTests(): boolean {
|
||||
if (process.env.VITEST) {
|
||||
return true;
|
||||
}
|
||||
return process.env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
export async function resolveBrowserOpenCommand(): Promise<BrowserOpenCommand> {
|
||||
const platform = process.platform;
|
||||
const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
||||
const isSsh =
|
||||
Boolean(process.env.SSH_CLIENT) ||
|
||||
Boolean(process.env.SSH_TTY) ||
|
||||
Boolean(process.env.SSH_CONNECTION);
|
||||
|
||||
if (isSsh && !hasDisplay && platform !== "win32") {
|
||||
return { argv: null, reason: "ssh-no-display" };
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
return {
|
||||
argv: ["cmd", "/c", "start", ""],
|
||||
command: "cmd",
|
||||
quoteUrl: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
const hasOpen = await detectBinary("open");
|
||||
return hasOpen ? { argv: ["open"], command: "open" } : { argv: null, reason: "missing-open" };
|
||||
}
|
||||
|
||||
if (platform === "linux") {
|
||||
const wsl = await isWSL();
|
||||
if (!hasDisplay && !wsl) {
|
||||
return { argv: null, reason: "no-display" };
|
||||
}
|
||||
if (wsl) {
|
||||
const hasWslview = await detectBinary("wslview");
|
||||
if (hasWslview) {
|
||||
return { argv: ["wslview"], command: "wslview" };
|
||||
}
|
||||
if (!hasDisplay) {
|
||||
return { argv: null, reason: "wsl-no-wslview" };
|
||||
}
|
||||
}
|
||||
const hasXdgOpen = await detectBinary("xdg-open");
|
||||
return hasXdgOpen
|
||||
? { argv: ["xdg-open"], command: "xdg-open" }
|
||||
: { argv: null, reason: "missing-xdg-open" };
|
||||
}
|
||||
|
||||
return { argv: null, reason: "unsupported-platform" };
|
||||
}
|
||||
|
||||
export async function detectBrowserOpenSupport(): Promise<BrowserOpenSupport> {
|
||||
const resolved = await resolveBrowserOpenCommand();
|
||||
if (!resolved.argv) {
|
||||
return { ok: false, reason: resolved.reason };
|
||||
}
|
||||
return { ok: true, command: resolved.command };
|
||||
}
|
||||
|
||||
export async function openUrl(url: string): Promise<boolean> {
|
||||
if (shouldSkipBrowserOpenInTests()) {
|
||||
return false;
|
||||
}
|
||||
const resolved = await resolveBrowserOpenCommand();
|
||||
if (!resolved.argv) {
|
||||
return false;
|
||||
}
|
||||
const quoteUrl = resolved.quoteUrl === true;
|
||||
const command = [...resolved.argv];
|
||||
if (quoteUrl) {
|
||||
if (command.at(-1) === "") {
|
||||
command[command.length - 1] = '""';
|
||||
}
|
||||
command.push(`"${url}"`);
|
||||
} else {
|
||||
command.push(url);
|
||||
}
|
||||
try {
|
||||
await runCommandWithTimeout(command, {
|
||||
timeoutMs: 5_000,
|
||||
windowsVerbatimArguments: quoteUrl,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openUrlInBackground(url: string): Promise<boolean> {
|
||||
if (shouldSkipBrowserOpenInTests()) {
|
||||
return false;
|
||||
}
|
||||
if (process.platform !== "darwin") {
|
||||
return false;
|
||||
}
|
||||
const resolved = await resolveBrowserOpenCommand();
|
||||
if (!resolved.argv || resolved.command !== "open") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await runCommandWithTimeout(["open", "-g", url], { timeoutMs: 5_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
buildRemoteCommand,
|
||||
buildSshSandboxArgv,
|
||||
createRemoteShellSandboxFsBridge,
|
||||
createWritableRenameTargetResolver,
|
||||
createSshSandboxSessionFromConfigText,
|
||||
createSshSandboxSessionFromSettings,
|
||||
disposeSshSandboxSession,
|
||||
|
||||
@@ -1,94 +1,6 @@
|
||||
import { isRemoteEnvironment } from "../infra/remote-env.js";
|
||||
import { isWSL } from "../infra/wsl.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { detectBinary } from "./setup-binary.js";
|
||||
|
||||
export {
|
||||
openUrl,
|
||||
resolveBrowserOpenCommand,
|
||||
type BrowserOpenCommand,
|
||||
} from "../infra/browser-open.js";
|
||||
export { isRemoteEnvironment } from "../infra/remote-env.js";
|
||||
|
||||
function shouldSkipBrowserOpenInTests(): boolean {
|
||||
if (process.env.VITEST) {
|
||||
return true;
|
||||
}
|
||||
return process.env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
type BrowserOpenCommand = {
|
||||
argv: string[] | null;
|
||||
command?: string;
|
||||
quoteUrl?: boolean;
|
||||
};
|
||||
|
||||
async function resolveBrowserOpenCommand(): Promise<BrowserOpenCommand> {
|
||||
const platform = process.platform;
|
||||
const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
||||
const isSsh =
|
||||
Boolean(process.env.SSH_CLIENT) ||
|
||||
Boolean(process.env.SSH_TTY) ||
|
||||
Boolean(process.env.SSH_CONNECTION);
|
||||
|
||||
if (isSsh && !hasDisplay && platform !== "win32") {
|
||||
return { argv: null };
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
return {
|
||||
argv: ["cmd", "/c", "start", ""],
|
||||
command: "cmd",
|
||||
quoteUrl: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
const hasOpen = await detectBinary("open");
|
||||
return hasOpen ? { argv: ["open"], command: "open" } : { argv: null };
|
||||
}
|
||||
|
||||
if (platform === "linux") {
|
||||
const wsl = await isWSL();
|
||||
if (!hasDisplay && !wsl) {
|
||||
return { argv: null };
|
||||
}
|
||||
if (wsl) {
|
||||
const hasWslview = await detectBinary("wslview");
|
||||
if (hasWslview) {
|
||||
return { argv: ["wslview"], command: "wslview" };
|
||||
}
|
||||
if (!hasDisplay) {
|
||||
return { argv: null };
|
||||
}
|
||||
}
|
||||
const hasXdgOpen = await detectBinary("xdg-open");
|
||||
return hasXdgOpen ? { argv: ["xdg-open"], command: "xdg-open" } : { argv: null };
|
||||
}
|
||||
|
||||
return { argv: null };
|
||||
}
|
||||
|
||||
export async function openUrl(url: string): Promise<boolean> {
|
||||
if (shouldSkipBrowserOpenInTests()) {
|
||||
return false;
|
||||
}
|
||||
const resolved = await resolveBrowserOpenCommand();
|
||||
if (!resolved.argv) {
|
||||
return false;
|
||||
}
|
||||
const quoteUrl = resolved.quoteUrl === true;
|
||||
const command = [...resolved.argv];
|
||||
if (quoteUrl) {
|
||||
if (command.at(-1) === "") {
|
||||
command[command.length - 1] = '""';
|
||||
}
|
||||
command.push(`"${url}"`);
|
||||
} else {
|
||||
command.push(url);
|
||||
}
|
||||
try {
|
||||
await runCommandWithTimeout(command, {
|
||||
timeoutMs: 5_000,
|
||||
windowsVerbatimArguments: quoteUrl,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user