Skip to content

Commit 0af6e68

Browse files
authored
Merge pull request #6 from zapta/main
Added automatic download and installation of apio
2 parents a48a7a5 + a6ad086 commit 0af6e68

13 files changed

Lines changed: 820 additions & 126 deletions

.github/workflows/build-and-release.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ jobs:
5555
- name: Verify npx
5656
run: npx --version
5757

58+
- name: Install dependencies
59+
run: npm ci
60+
5861
- name: Build extension package
5962
run: |
6063
ls -al

.vscode/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
"gowin",
99
"icestorm",
1010
"makefiles",
11+
"pyinstaller",
1112
"systemverilog",
1213
"testbench",
1314
"testbenchs",
1415
"verilog",
16+
"xattr",
1517
"yosys",
1618
"Yoyodyne",
1719
"zapta"

.vscodeignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
node_modules
21
.git
32
.gitignore
43
.vscode

apio-downloader.js

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// apio-downloader.js
2+
// Ensures that the apio pyinstaller bundle is installed and that
3+
// the apio binary is available at ~/.apio/bin/apio[.exe]. If not,
4+
// It's downloaded and installed on the fly.
5+
6+
"use strict";
7+
8+
// Standard imports
9+
const fs = require("fs");
10+
const path = require("path");
11+
const os = require("os");
12+
const stream = require("node:stream");
13+
const childProcess = require("child_process");
14+
const assert = require("node:assert");
15+
16+
// Dependency imports
17+
const zipExtract = require("extract-zip");
18+
const tar = require("tar");
19+
20+
// Local imports
21+
const platforms = require("./apio-platforms.js");
22+
const apioLog = require("./apio-log.js");
23+
24+
// The release to download
25+
const apioReleaseTag = "2025-11-17";
26+
const githubRepo = "FPGAwars/apio-dev-builds";
27+
28+
// Environment. Initialized on first call to ensureApioBinary
29+
let _apioBinDirPath = null;
30+
let _apioTmpDirPath = null;
31+
let _apioBinaryName = null;
32+
let _apioBinaryPath = null;
33+
34+
// Initializes this module. Should be called once before any other
35+
// function of this module.
36+
function init() {
37+
// Should be called only once.
38+
assert(
39+
_apioBinDirPath == null,
40+
"apio-downloader.init() should be called at most once."
41+
);
42+
43+
// Get the absolute path to the user home dir.
44+
const homeDir = os.homedir();
45+
apioLog.msg(`User home dir: ${homeDir}`);
46+
47+
// Determine the absolute path of ~/.apio/tmp. We will
48+
// use it as a temporary storage of the downloaded package.
49+
_apioTmpDirPath = path.join(homeDir, ".apio", "tmp");
50+
apioLog.msg(`Apio tmp dir: ${_apioTmpDirPath}`);
51+
52+
// Determine the absolute path of ~/.apio/bin, this is
53+
// where we will store the apio binary and supporting files.
54+
_apioBinDirPath = path.join(homeDir, ".apio", "bin");
55+
apioLog.msg(`Apio bin dir: ${_apioBinDirPath}`);
56+
57+
// Determine apio binary name
58+
_apioBinaryName = platforms.isWindows() ? "apio.exe" : "apio";
59+
60+
// Determine the absolute path of the apio binary name.
61+
_apioBinaryPath = path.join(_apioBinDirPath, _apioBinaryName);
62+
apioLog.msg(`Apio binary path: ${_apioBinaryPath}`);
63+
}
64+
65+
// Ensures the Apio binary is ready and if not download and installs it.
66+
// @returns {Promise<string>} with the path to apio / apio.exe
67+
async function ensureApioBinary() {
68+
// Check if the binary exists.
69+
const binaryOk = await _testFsItem(
70+
_apioBinaryPath,
71+
fs.constants.X_OK | fs.constants.R_OK
72+
);
73+
74+
if (binaryOk) {
75+
// Binary is good, will use it.
76+
apioLog.msg(`Apio binary found: ${_apioBinDirPath}`);
77+
} else {
78+
// Binary is missing or not good, will download and install it.
79+
apioLog.msg("Apio binary not found, will install.");
80+
await _downloadAndInstall();
81+
}
82+
83+
return _apioBinaryPath;
84+
}
85+
86+
// Download the apio bundle and install it.
87+
// This async function returns a promise that govern the process.
88+
async function _downloadAndInstall() {
89+
90+
const yyyymmdd = apioReleaseTag.replaceAll("-", "");
91+
const platformId = platforms.getPlatformId();
92+
const extension = platforms.isWindows() ? "zip" : "tgz";
93+
94+
const baseUrl = `https://github.com/${githubRepo}/releases/download/${apioReleaseTag}/`;
95+
const archiveName = `apio-${platformId}-${yyyymmdd}-bundle.${extension}`;
96+
const url = baseUrl + archiveName;
97+
const archivePath = path.join(_apioTmpDirPath, archiveName);
98+
99+
apioLog.msg(`[Apio] Downloading: ${url}`);
100+
101+
// Make the tmp dir if doesn't exist.
102+
await fs.promises.mkdir(_apioTmpDirPath, { recursive: true });
103+
104+
// Download the file to the tmp dir.
105+
await _downloadFile(url, archivePath);
106+
107+
// Remove quarantine (macOS) from the archive.
108+
//
109+
// TODO: Do we really need this or is the bundle ok because we download
110+
// from VSCode?
111+
if (platforms.isDarwin()) {
112+
await new Promise((res) => {
113+
childProcess.exec(
114+
`xattr -d com.apple.quarantine "${archivePath}"`,
115+
(err) => {
116+
if (err) {
117+
console.warn("[Apio] Quarantine removal failed:", err.message);
118+
apioLog.msg("MacOS quarantine removal was not performed");
119+
} else {
120+
console.log("[Apio] Quarantine removed");
121+
apioLog.msg("MaxOS quarantine removal was performed");
122+
}
123+
res();
124+
}
125+
);
126+
});
127+
}
128+
129+
// Extract
130+
if (archiveName.endsWith(".zip")) {
131+
await zipExtract(archivePath, { dir: _apioTmpDirPath });
132+
} else if (archiveName.endsWith(".tgz")) {
133+
await tar.x({ file: archivePath, cwd: _apioTmpDirPath });
134+
}
135+
136+
// Clean up archive. We don't need it any more.
137+
await fs.promises.unlink(archivePath).catch(() => {});
138+
139+
// Expected: tmpDir/apio/
140+
const extractedApioDir = path.join(_apioTmpDirPath, "apio");
141+
// if (!(await fileExists(extractedApioDir))) {
142+
if (!(await _testFsItem(extractedApioDir))) {
143+
throw new Error('Archive did not contain "apio/" directory');
144+
}
145+
146+
// Ensure binDir exists, make an empty one if not.
147+
await fs.promises.mkdir(_apioBinDirPath, { recursive: true });
148+
149+
// Remove old binDir (overwrite)
150+
await fs.promises
151+
.rm(_apioBinDirPath, { recursive: true, force: true })
152+
.catch(() => {});
153+
154+
// Move apio/ → binDir
155+
await fs.promises.rename(extractedApioDir, _apioBinDirPath);
156+
157+
// const apioBinaryPath = path.join(_apioBinDirPath, ap);
158+
// if (!(await fileExists(_apioBinaryPath))) {
159+
if (!(await _testFsItem(_apioBinaryPath))) {
160+
throw new Error("Apio binary not found after extraction");
161+
}
162+
163+
// Make the apio binary executable
164+
if (!platforms.isWindows()) {
165+
await fs.promises.chmod(_apioBinaryPath, 0o755);
166+
}
167+
}
168+
169+
// fetch is global in Node.js 18+ (no require needed at all!)
170+
async function _downloadFile(url, dest, maxRedirects = 5) {
171+
let currentUrl = url;
172+
let redirectsLeft = maxRedirects;
173+
174+
while (redirectsLeft >= 0) {
175+
const res = await fetch(currentUrl, {
176+
headers: {
177+
"User-Agent": "vscode-apio-extension",
178+
Accept: "application/octet-stream",
179+
},
180+
redirect: "manual",
181+
});
182+
183+
apioLog.msg(`HTTP ${res.status}`);
184+
185+
if (
186+
[301, 302, 303, 307, 308].includes(res.status) &&
187+
res.headers.get("location")
188+
) {
189+
currentUrl = new URL(res.headers.get("location"), currentUrl).href;
190+
apioLog.msg("URL redirect.");
191+
redirectsLeft--;
192+
continue;
193+
}
194+
195+
if (res.status !== 200 || !res.body) {
196+
throw new Error(`HTTP error ${res.status}`);
197+
}
198+
199+
// TODO: Move the require to the import and use here stream.promises.pipeline()
200+
// await require("node:stream/promises").pipeline(res.body, fs.createWriteStream(dest));
201+
await stream.promises.pipeline(res.body, fs.createWriteStream(dest));
202+
apioLog.msg(`Downloaded ${dest}`);
203+
return;
204+
}
205+
206+
throw new Error("Too many redirects");
207+
}
208+
209+
// Similar to fs.promises.access but return true/false instead
210+
// of success/exception.
211+
async function _testFsItem(path, mode = fs.constants.F_OK) {
212+
try {
213+
await fs.promises.access(path, mode);
214+
return true;
215+
} catch {
216+
return false;
217+
}
218+
}
219+
220+
/* ------------------------------------------------------------------ */
221+
module.exports = { init, ensureApioBinary };

apio-log.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// apio-log.js
2+
// Centralized logging for the Apio extension.
3+
4+
"use strict";
5+
6+
const vscode = require("vscode");
7+
8+
let outputChannel = null;
9+
10+
// Initializes the Apio output channel and push it to the extension context.
11+
// Must be called **once** from `activate(context)`.
12+
// @param {vscode.ExtensionContext} context
13+
function init(context) {
14+
if (outputChannel) {
15+
return; // already initialized
16+
}
17+
18+
outputChannel = vscode.window.createOutputChannel("Apio");
19+
context.subscriptions.push(outputChannel);
20+
}
21+
22+
// Append a message to the Apio output channel.
23+
// @param {string} line Message to log
24+
// @param {boolean} [show=false] If true, the channel is shown (preserves focus)
25+
function msg(line, show = false) {
26+
if (!outputChannel) {
27+
// Fallback: create a temporary channel if initLog was never called.
28+
// This should never happen in normal operation.
29+
outputChannel = vscode.window.createOutputChannel("Apio");
30+
}
31+
32+
outputChannel.appendLine(line);
33+
34+
if (show) {
35+
outputChannel.show(true); // true = preserve focus on the editor
36+
}
37+
}
38+
39+
// Explicitly show the Apio output channel (preserves focus).
40+
function showChannel() {
41+
if (outputChannel) {
42+
outputChannel.show(true);
43+
}
44+
}
45+
46+
module.exports = {
47+
init,
48+
msg,
49+
showChannel,
50+
};

apio-platforms.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* platforms.js
3+
* -------------
4+
* Platform detection utilities for VS Code extensions.
5+
*
6+
* Exported API:
7+
* getPlatformId() → string // one of the 5 standardized IDs (cached)
8+
* isWindows() → boolean
9+
* isDarwin() → boolean
10+
* isLinux() → boolean
11+
*
12+
* Supported platform IDs:
13+
* darwin-arm64, darwin-x86-64, linux-x86-64,
14+
* linux-aarch64, windows-amd64
15+
*/
16+
17+
"use strict";
18+
19+
const process = require("process");
20+
21+
/**
22+
* Cached platform identifier.
23+
* Set on first successful call to getPlatformId().
24+
* @type {string|null}
25+
*/
26+
let _cachedPlatformId = null;
27+
28+
/**
29+
* Returns the standardized platform identifier.
30+
* The result is cached after the first successful call.
31+
*
32+
* @returns {string} One of the five supported platform IDs.
33+
* @throws {Error} If the platform/architecture is unsupported.
34+
*/
35+
function getPlatformId() {
36+
// Return cached value if already computed
37+
if (_cachedPlatformId !== null) {
38+
return _cachedPlatformId;
39+
}
40+
41+
const platform = process.platform; // 'darwin' | 'linux' | 'win32' | ...
42+
const arch = process.arch; // 'arm64' | 'x64' | ...
43+
44+
let id;
45+
46+
// macOS
47+
if (platform === "darwin") {
48+
if (arch === "arm64") id = "darwin-arm64";
49+
else if (arch === "x64") id = "darwin-x86-64";
50+
}
51+
// Linux
52+
else if (platform === "linux") {
53+
if (arch === "arm64") id = "linux-aarch64";
54+
else if (arch === "x64") id = "linux-x86-64";
55+
}
56+
// Windows
57+
else if (platform === "win32") {
58+
if (arch === "x64") id = "windows-amd64";
59+
}
60+
61+
if (!id) {
62+
throw new Error(`Unsupported platform: ${platform}-${arch}`);
63+
}
64+
65+
// Cache and return
66+
_cachedPlatformId = id;
67+
return id;
68+
}
69+
70+
/**
71+
* Quick boolean checks – all based on the cached platform ID.
72+
*/
73+
function isWindows() {
74+
return getPlatformId().startsWith("windows-");
75+
}
76+
function isDarwin() {
77+
return getPlatformId().startsWith("darwin-");
78+
}
79+
function isLinux() {
80+
return getPlatformId().startsWith("linux-");
81+
}
82+
83+
/* ------------------------------------------------------------------ */
84+
/* Module exports */
85+
/* ------------------------------------------------------------------ */
86+
module.exports = {
87+
getPlatformId,
88+
isWindows,
89+
isDarwin,
90+
isLinux,
91+
};

0 commit comments

Comments
 (0)