Skip to content

Commit 2bf9753

Browse files
committed
Install Toolchain
1 parent 2bdaabe commit 2bf9753

File tree

2 files changed

+261
-12
lines changed

2 files changed

+261
-12
lines changed

src/toolchain/swiftly.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
import * as path from "path";
1616
import { SwiftlyConfig } from "./ToolchainVersion";
1717
import * as fs from "fs/promises";
18+
import * as fsSync from "fs";
19+
import * as os from "os";
20+
import * as readline from "readline";
1821
import { execFile, ExecFileError } from "../utilities/utilities";
1922
import * as vscode from "vscode";
2023
import { Version } from "../utilities/version";
@@ -51,6 +54,45 @@ const InUseVersionResult = z.object({
5154
version: z.string(),
5255
});
5356

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+
5496
export class Swiftly {
5597
/**
5698
* Finds the version of Swiftly installed on the system.
@@ -219,6 +261,145 @@ export class Swiftly {
219261
return undefined;
220262
}
221263

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+
222403
/**
223404
* Reads the Swiftly configuration file, if it exists.
224405
*

src/ui/ToolchainSelection.ts

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { showReloadExtensionNotification } from "./ReloadExtension";
1818
import { SwiftToolchain } from "../toolchain/toolchain";
1919
import configuration from "../configuration";
2020
import { Commands } from "../commands";
21-
import { Swiftly } from "../toolchain/swiftly";
21+
import { Swiftly, SwiftlyProgressData } from "../toolchain/swiftly";
2222
import { SwiftLogger } from "../logging/SwiftLogger";
2323

2424
/**
@@ -133,6 +133,12 @@ interface SwiftlyToolchainItem extends BaseSwiftToolchainItem {
133133
version: string;
134134
}
135135

136+
interface InstallableToolchainItem extends BaseSwiftToolchainItem {
137+
category: "installable";
138+
version: string;
139+
toolchainType: "stable" | "snapshot";
140+
}
141+
136142
/** A {@link vscode.QuickPickItem} that separates items in the UI */
137143
class SeparatorItem implements vscode.QuickPickItem {
138144
readonly type = "separator";
@@ -145,7 +151,11 @@ class SeparatorItem implements vscode.QuickPickItem {
145151
}
146152

147153
/** The possible types of {@link vscode.QuickPickItem} in the toolchain selection dialog */
148-
type SelectToolchainItem = SwiftToolchainItem | ActionItem | SeparatorItem;
154+
type SelectToolchainItem =
155+
| SwiftToolchainItem
156+
| InstallableToolchainItem
157+
| ActionItem
158+
| SeparatorItem;
149159

150160
/**
151161
* Retrieves all {@link SelectToolchainItem} that are available on the system.
@@ -223,7 +233,68 @@ async function getQuickPickItems(
223233
}
224234
},
225235
}));
226-
// Mark which toolchain is being actively used
236+
237+
const installableToolchains = (await Swiftly.listAvailable(logger))
238+
.filter(toolchain => !toolchain.isInstalled)
239+
.reverse()
240+
.map<InstallableToolchainItem>(toolchain => ({
241+
type: "toolchain",
242+
label: `$(cloud-download) ${toolchain.name} (${toolchain.type})`,
243+
// detail: `Install ${toolchain.type} release`,
244+
category: "installable",
245+
version: toolchain.name,
246+
toolchainType: toolchain.type,
247+
onDidSelect: async () => {
248+
try {
249+
await vscode.window.withProgress(
250+
{
251+
location: vscode.ProgressLocation.Notification,
252+
title: `Installing Swift ${toolchain.name}`,
253+
cancellable: false,
254+
},
255+
async progress => {
256+
progress.report({ message: "Starting installation..." });
257+
258+
let lastProgress = 0;
259+
260+
await Swiftly.installToolchain(
261+
toolchain.name,
262+
(progressData: SwiftlyProgressData) => {
263+
if (
264+
progressData.step?.percent !== undefined &&
265+
progressData.step.percent > lastProgress
266+
) {
267+
const increment = progressData.step.percent - lastProgress;
268+
progress.report({
269+
increment,
270+
message:
271+
progressData.step.text ||
272+
`${progressData.step.percent}% complete`,
273+
});
274+
lastProgress = progressData.step.percent;
275+
}
276+
},
277+
logger
278+
);
279+
280+
progress.report({
281+
increment: 100 - lastProgress,
282+
message: "Installation complete",
283+
});
284+
}
285+
);
286+
287+
void showReloadExtensionNotification(
288+
`Swift ${toolchain.name} has been installed and activated. Visual Studio Code needs to be reloaded.`
289+
);
290+
} catch (error) {
291+
logger?.error(`Failed to install Swift ${toolchain.name}: ${error}`);
292+
void vscode.window.showErrorMessage(
293+
`Failed to install Swift ${toolchain.name}: ${error}`
294+
);
295+
}
296+
},
297+
}));
227298
if (activeToolchain) {
228299
const currentSwiftlyVersion = activeToolchain.isSwiftlyManaged
229300
? await Swiftly.inUseVersion("swiftly", cwd)
@@ -286,6 +357,9 @@ async function getQuickPickItems(
286357
...(swiftlyToolchains.length > 0
287358
? [new SeparatorItem("swiftly"), ...swiftlyToolchains]
288359
: []),
360+
...(installableToolchains.length > 0
361+
? [new SeparatorItem("available for install"), ...installableToolchains]
362+
: []),
289363
new SeparatorItem("actions"),
290364
...actionItems,
291365
];
@@ -345,17 +419,11 @@ export async function showToolchainSelectionQuickPick(
345419
// Update the toolchain path`
346420
let swiftPath: string | undefined;
347421

348-
// Handle Swiftly toolchains specially
349-
if (selected.category === "swiftly") {
350-
try {
351-
swiftPath = undefined;
352-
} catch (error) {
353-
void vscode.window.showErrorMessage(`Failed to switch Swiftly toolchain: ${error}`);
354-
return;
355-
}
422+
if (selected.category === "swiftly" || selected.category === "installable") {
423+
swiftPath = undefined;
356424
} else {
357425
// For non-Swiftly toolchains, use the swiftFolderPath
358-
swiftPath = selected.swiftFolderPath;
426+
swiftPath = (selected as PublicSwiftToolchainItem | XcodeToolchainItem).swiftFolderPath;
359427
}
360428

361429
const isUpdated = await setToolchainPath(swiftPath, developerDir);

0 commit comments

Comments
 (0)