Skip to content

Commit 58ff42b

Browse files
committed
Install Toolchain
1 parent 2bdaabe commit 58ff42b

File tree

3 files changed

+473
-13
lines changed

3 files changed

+473
-13
lines changed

src/toolchain/swiftly.ts

Lines changed: 183 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,147 @@ 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+
if (process.platform === "linux") {
329+
logger?.info(
330+
`Skipping toolchain installation on Linux as it requires PostInstall steps`
331+
);
332+
return;
333+
}
334+
335+
logger?.info(`Installing toolchain ${version} via swiftly`);
336+
337+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-"));
338+
const postInstallFilePath = path.join(tmpDir, `post-install-${version}.sh`);
339+
340+
let progressPipePath: string | undefined;
341+
let progressPromise: Promise<void> | undefined;
342+
343+
if (progressCallback) {
344+
progressPipePath = path.join(tmpDir, `progress-${version}.pipe`);
345+
346+
await execFile("mkfifo", [progressPipePath]);
347+
348+
progressPromise = new Promise<void>((resolve, reject) => {
349+
const rl = readline.createInterface({
350+
input: fsSync.createReadStream(progressPipePath!),
351+
crlfDelay: Infinity,
352+
});
353+
354+
rl.on("line", (line: string) => {
355+
try {
356+
const progressData = JSON.parse(line.trim()) as SwiftlyProgressData;
357+
progressCallback(progressData);
358+
} catch (err) {
359+
logger?.error(`Failed to parse progress line: ${err}`);
360+
}
361+
});
362+
363+
rl.on("close", () => {
364+
resolve();
365+
});
366+
367+
rl.on("error", err => {
368+
reject(err);
369+
});
370+
});
371+
}
372+
373+
const installArgs = [
374+
"install",
375+
version,
376+
"--use",
377+
"--assume-yes",
378+
"--post-install-file",
379+
postInstallFilePath,
380+
];
381+
382+
if (progressPipePath) {
383+
installArgs.push("--progress-file", progressPipePath);
384+
}
385+
386+
try {
387+
const installPromise = execFile("swiftly", installArgs);
388+
389+
if (progressPromise) {
390+
await Promise.all([installPromise, progressPromise]);
391+
} else {
392+
await installPromise;
393+
}
394+
} finally {
395+
if (progressPipePath) {
396+
try {
397+
await fs.unlink(progressPipePath);
398+
} catch {
399+
// Ignore errors if the pipe file doesn't exist
400+
}
401+
}
402+
}
403+
}
404+
222405
/**
223406
* Reads the Swiftly configuration file, if it exists.
224407
*

src/ui/ToolchainSelection.ts

Lines changed: 84 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,72 @@ async function getQuickPickItems(
223233
}
224234
},
225235
}));
226-
// Mark which toolchain is being actively used
236+
237+
const installableToolchains =
238+
process.platform === "linux"
239+
? []
240+
: (await Swiftly.listAvailable(logger))
241+
.filter(toolchain => !toolchain.isInstalled)
242+
.reverse()
243+
.map<InstallableToolchainItem>(toolchain => ({
244+
type: "toolchain",
245+
label: `$(cloud-download) ${toolchain.name} (${toolchain.type})`,
246+
// detail: `Install ${toolchain.type} release`,
247+
category: "installable",
248+
version: toolchain.name,
249+
toolchainType: toolchain.type,
250+
onDidSelect: async () => {
251+
try {
252+
await vscode.window.withProgress(
253+
{
254+
location: vscode.ProgressLocation.Notification,
255+
title: `Installing Swift ${toolchain.name}`,
256+
cancellable: false,
257+
},
258+
async progress => {
259+
progress.report({ message: "Starting installation..." });
260+
261+
let lastProgress = 0;
262+
263+
await Swiftly.installToolchain(
264+
toolchain.name,
265+
(progressData: SwiftlyProgressData) => {
266+
if (
267+
progressData.step?.percent !== undefined &&
268+
progressData.step.percent > lastProgress
269+
) {
270+
const increment =
271+
progressData.step.percent - lastProgress;
272+
progress.report({
273+
increment,
274+
message:
275+
progressData.step.text ||
276+
`${progressData.step.percent}% complete`,
277+
});
278+
lastProgress = progressData.step.percent;
279+
}
280+
},
281+
logger
282+
);
283+
284+
progress.report({
285+
increment: 100 - lastProgress,
286+
message: "Installation complete",
287+
});
288+
}
289+
);
290+
291+
void showReloadExtensionNotification(
292+
`Swift ${toolchain.name} has been installed and activated. Visual Studio Code needs to be reloaded.`
293+
);
294+
} catch (error) {
295+
logger?.error(`Failed to install Swift ${toolchain.name}: ${error}`);
296+
void vscode.window.showErrorMessage(
297+
`Failed to install Swift ${toolchain.name}: ${error}`
298+
);
299+
}
300+
},
301+
}));
227302
if (activeToolchain) {
228303
const currentSwiftlyVersion = activeToolchain.isSwiftlyManaged
229304
? await Swiftly.inUseVersion("swiftly", cwd)
@@ -286,6 +361,9 @@ async function getQuickPickItems(
286361
...(swiftlyToolchains.length > 0
287362
? [new SeparatorItem("swiftly"), ...swiftlyToolchains]
288363
: []),
364+
...(installableToolchains.length > 0
365+
? [new SeparatorItem("available for install"), ...installableToolchains]
366+
: []),
289367
new SeparatorItem("actions"),
290368
...actionItems,
291369
];
@@ -345,17 +423,11 @@ export async function showToolchainSelectionQuickPick(
345423
// Update the toolchain path`
346424
let swiftPath: string | undefined;
347425

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-
}
426+
if (selected.category === "swiftly" || selected.category === "installable") {
427+
swiftPath = undefined;
356428
} else {
357429
// For non-Swiftly toolchains, use the swiftFolderPath
358-
swiftPath = selected.swiftFolderPath;
430+
swiftPath = (selected as PublicSwiftToolchainItem | XcodeToolchainItem).swiftFolderPath;
359431
}
360432

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

0 commit comments

Comments
 (0)