|
15 | 15 | import * as path from "path";
|
16 | 16 | import { SwiftlyConfig } from "./ToolchainVersion";
|
17 | 17 | import * as fs from "fs/promises";
|
| 18 | +import * as fsSync from "fs"; |
| 19 | +import * as os from "os"; |
| 20 | +import * as readline from "readline"; |
18 | 21 | import { execFile, ExecFileError } from "../utilities/utilities";
|
19 | 22 | import * as vscode from "vscode";
|
20 | 23 | import { Version } from "../utilities/version";
|
@@ -51,6 +54,45 @@ const InUseVersionResult = z.object({
|
51 | 54 | version: z.string(),
|
52 | 55 | });
|
53 | 56 |
|
| 57 | +const ListAvailableResult = z.object({ |
| 58 | + toolchains: z.array( |
| 59 | + z.object({ |
| 60 | + version: z.discriminatedUnion("type", [ |
| 61 | + z.object({ |
| 62 | + major: z.union([z.number(), z.undefined()]), |
| 63 | + minor: z.union([z.number(), z.undefined()]), |
| 64 | + patch: z.union([z.number(), z.undefined()]), |
| 65 | + name: z.string(), |
| 66 | + type: z.literal("stable"), |
| 67 | + }), |
| 68 | + z.object({ |
| 69 | + major: z.union([z.number(), z.undefined()]), |
| 70 | + minor: z.union([z.number(), z.undefined()]), |
| 71 | + branch: z.string(), |
| 72 | + date: z.string(), |
| 73 | + name: z.string(), |
| 74 | + type: z.literal("snapshot"), |
| 75 | + }), |
| 76 | + ]), |
| 77 | + }) |
| 78 | + ), |
| 79 | +}); |
| 80 | + |
| 81 | +export interface AvailableToolchain { |
| 82 | + name: string; |
| 83 | + type: "stable" | "snapshot"; |
| 84 | + version: string; |
| 85 | + isInstalled: boolean; |
| 86 | +} |
| 87 | + |
| 88 | +export interface SwiftlyProgressData { |
| 89 | + step?: { |
| 90 | + text?: string; |
| 91 | + timestamp?: number; |
| 92 | + percent?: number; |
| 93 | + }; |
| 94 | +} |
| 95 | + |
54 | 96 | export class Swiftly {
|
55 | 97 | /**
|
56 | 98 | * Finds the version of Swiftly installed on the system.
|
@@ -219,6 +261,145 @@ export class Swiftly {
|
219 | 261 | return undefined;
|
220 | 262 | }
|
221 | 263 |
|
| 264 | + /** |
| 265 | + * Lists all toolchains available for installation from swiftly |
| 266 | + * |
| 267 | + * @param logger Optional logger for error reporting |
| 268 | + * @returns Array of available toolchains |
| 269 | + */ |
| 270 | + public static async listAvailable(logger?: SwiftLogger): Promise<AvailableToolchain[]> { |
| 271 | + if (!this.isSupported()) { |
| 272 | + return []; |
| 273 | + } |
| 274 | + |
| 275 | + const version = await Swiftly.version(logger); |
| 276 | + if (!version) { |
| 277 | + logger?.warn("Swiftly is not installed"); |
| 278 | + return []; |
| 279 | + } |
| 280 | + |
| 281 | + if (!(await Swiftly.supportsJsonOutput(logger))) { |
| 282 | + logger?.warn("Swiftly version does not support JSON output for list-available"); |
| 283 | + return []; |
| 284 | + } |
| 285 | + |
| 286 | + try { |
| 287 | + const { stdout: availableStdout } = await execFile("swiftly", [ |
| 288 | + "list-available", |
| 289 | + "--format=json", |
| 290 | + ]); |
| 291 | + const availableResponse = ListAvailableResult.parse(JSON.parse(availableStdout)); |
| 292 | + |
| 293 | + const { stdout: installedStdout } = await execFile("swiftly", [ |
| 294 | + "list", |
| 295 | + "--format=json", |
| 296 | + ]); |
| 297 | + const installedResponse = ListResult.parse(JSON.parse(installedStdout)); |
| 298 | + const installedNames = new Set(installedResponse.toolchains.map(t => t.version.name)); |
| 299 | + |
| 300 | + return availableResponse.toolchains.map(toolchain => ({ |
| 301 | + name: toolchain.version.name, |
| 302 | + type: toolchain.version.type, |
| 303 | + version: toolchain.version.name, |
| 304 | + isInstalled: installedNames.has(toolchain.version.name), |
| 305 | + })); |
| 306 | + } catch (error) { |
| 307 | + logger?.error(`Failed to retrieve available Swiftly toolchains: ${error}`); |
| 308 | + return []; |
| 309 | + } |
| 310 | + } |
| 311 | + |
| 312 | + /** |
| 313 | + * Installs a toolchain via swiftly with optional progress tracking |
| 314 | + * |
| 315 | + * @param version The toolchain version to install |
| 316 | + * @param progressCallback Optional callback that receives progress data as JSON objects |
| 317 | + * @param logger Optional logger for error reporting |
| 318 | + */ |
| 319 | + public static async installToolchain( |
| 320 | + version: string, |
| 321 | + progressCallback?: (progressData: SwiftlyProgressData) => void, |
| 322 | + logger?: SwiftLogger |
| 323 | + ): Promise<void> { |
| 324 | + if (!this.isSupported()) { |
| 325 | + throw new Error("Swiftly is not supported on this platform"); |
| 326 | + } |
| 327 | + |
| 328 | + logger?.info(`Installing toolchain ${version} via swiftly`); |
| 329 | + |
| 330 | + const tmpDir = os.tmpdir(); |
| 331 | + const sessionId = Math.random().toString(36).substring(2); |
| 332 | + const postInstallFilePath = `${tmpDir}/vscode-swift-${sessionId}/post-install.sh`; |
| 333 | + |
| 334 | + await fs.mkdir(`${tmpDir}/vscode-swift-${sessionId}`, { recursive: true }); |
| 335 | + await fs.writeFile(postInstallFilePath, "", "utf8"); |
| 336 | + |
| 337 | + let progressPipePath: string | undefined; |
| 338 | + let progressPromise: Promise<void> | undefined; |
| 339 | + |
| 340 | + if (progressCallback) { |
| 341 | + progressPipePath = `${tmpDir}/vscode-swift-${sessionId}-progress.pipe`; |
| 342 | + |
| 343 | + await execFile("mkfifo", [progressPipePath]); |
| 344 | + |
| 345 | + progressPromise = new Promise<void>((resolve, reject) => { |
| 346 | + const rl = readline.createInterface({ |
| 347 | + input: fsSync.createReadStream(progressPipePath!), |
| 348 | + crlfDelay: Infinity, |
| 349 | + }); |
| 350 | + |
| 351 | + rl.on("line", (line: string) => { |
| 352 | + try { |
| 353 | + const progressData = JSON.parse(line.trim()) as SwiftlyProgressData; |
| 354 | + progressCallback(progressData); |
| 355 | + } catch (err) { |
| 356 | + logger?.error(`Failed to parse progress line: ${err}`); |
| 357 | + // Continue monitoring despite parsing errors |
| 358 | + } |
| 359 | + }); |
| 360 | + |
| 361 | + rl.on("close", () => { |
| 362 | + resolve(); |
| 363 | + }); |
| 364 | + |
| 365 | + rl.on("error", err => { |
| 366 | + reject(err); |
| 367 | + }); |
| 368 | + }); |
| 369 | + } |
| 370 | + |
| 371 | + const installArgs = [ |
| 372 | + "install", |
| 373 | + version, |
| 374 | + "--use", |
| 375 | + "--assume-yes", |
| 376 | + "--post-install-file", |
| 377 | + postInstallFilePath, |
| 378 | + ]; |
| 379 | + |
| 380 | + if (progressPipePath) { |
| 381 | + installArgs.push("--progress-file", progressPipePath); |
| 382 | + } |
| 383 | + |
| 384 | + try { |
| 385 | + const installPromise = execFile("swiftly", installArgs); |
| 386 | + |
| 387 | + if (progressPromise) { |
| 388 | + await Promise.all([installPromise, progressPromise]); |
| 389 | + } else { |
| 390 | + await installPromise; |
| 391 | + } |
| 392 | + } finally { |
| 393 | + if (progressPipePath) { |
| 394 | + try { |
| 395 | + await fs.unlink(progressPipePath); |
| 396 | + } catch { |
| 397 | + // Ignore errors if the pipe file doesn't exist |
| 398 | + } |
| 399 | + } |
| 400 | + } |
| 401 | + } |
| 402 | + |
222 | 403 | /**
|
223 | 404 | * Reads the Swiftly configuration file, if it exists.
|
224 | 405 | *
|
|
0 commit comments