mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-30 07:21:32 -07:00
fix(linux): auto-install managed plugin copy; include in asset updates (#127)
This commit is contained in:
@@ -9,18 +9,43 @@ import {
|
||||
buildProtectedSupportAssetsCommand,
|
||||
detectSupportAssetDataDirs,
|
||||
updateSupportAssetsFromRelease,
|
||||
type SupportAssetsUpdateResult,
|
||||
} from './support-assets';
|
||||
|
||||
type SupportAssetsResultWithComponent = SupportAssetsUpdateResult & {
|
||||
component?: 'theme' | 'plugin';
|
||||
};
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
|
||||
function makeSupportAssetsArchive(options?: {
|
||||
themeContent?: string;
|
||||
pluginVersion?: string | null;
|
||||
pluginMainContent?: string;
|
||||
extraPluginFiles?: Array<{ relativePath: string; content: string }>;
|
||||
}): { archive: Buffer; tempDir: string } {
|
||||
const themeContent = options?.themeContent ?? 'new theme\n';
|
||||
const pluginVersion = options && 'pluginVersion' in options ? options.pluginVersion : '0.12.0';
|
||||
const pluginMainContent = options?.pluginMainContent ?? 'new plugin\n';
|
||||
const extraPluginFiles = options?.extraPluginFiles ?? [];
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'assets/themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), 'new theme\n');
|
||||
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), 'new plugin\n');
|
||||
fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), themeContent);
|
||||
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), pluginMainContent);
|
||||
if (pluginVersion !== null) {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'plugin/subminer/version.lua'),
|
||||
`return {\n\tversion = "${pluginVersion}",\n}\n`,
|
||||
);
|
||||
}
|
||||
for (const extraFile of extraPluginFiles) {
|
||||
const targetPath = path.join(tempDir, 'plugin/subminer', extraFile.relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, extraFile.content);
|
||||
}
|
||||
execFileSync('tar', ['-czf', 'subminer-assets.tar.gz', 'assets', 'plugin'], { cwd: tempDir });
|
||||
return {
|
||||
archive: fs.readFileSync(path.join(tempDir, 'subminer-assets.tar.gz')),
|
||||
@@ -28,7 +53,29 @@ function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
|
||||
};
|
||||
}
|
||||
|
||||
test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () => {
|
||||
async function runLinuxSupportAssetUpdate(options: {
|
||||
archive: Buffer;
|
||||
xdgDataHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
}): Promise<SupportAssetsResultWithComponent[]> {
|
||||
return (await updateSupportAssetsFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
assets: [
|
||||
{
|
||||
name: 'subminer-assets.tar.gz',
|
||||
browser_download_url: 'https://example.test/subminer-assets.tar.gz',
|
||||
},
|
||||
],
|
||||
},
|
||||
sha256Sums: new Map([['subminer-assets.tar.gz', sha256(options.archive)]]),
|
||||
downloadAsset: async () => options.archive,
|
||||
platform: options.platform ?? 'linux',
|
||||
xdgDataHome: options.xdgDataHome,
|
||||
})) as SupportAssetsResultWithComponent[];
|
||||
}
|
||||
|
||||
test('detectSupportAssetDataDirs only returns Linux support-asset locations', () => {
|
||||
assert.deepEqual(
|
||||
detectSupportAssetDataDirs({
|
||||
platform: 'darwin',
|
||||
@@ -46,9 +93,10 @@ test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('buildProtectedSupportAssetsCommand cleans up temporary extraction directory', () => {
|
||||
test('buildProtectedSupportAssetsCommand installs both theme and plugin assets', () => {
|
||||
const command = buildProtectedSupportAssetsCommand(
|
||||
"https://example.test/subminer assets.tar.gz?sig='abc'",
|
||||
'ABCDEF1234',
|
||||
"/usr/local/share/SubMiner's data",
|
||||
);
|
||||
|
||||
@@ -58,46 +106,419 @@ test('buildProtectedSupportAssetsCommand cleans up temporary extraction director
|
||||
command,
|
||||
/curl -fSL 'https:\/\/example\.test\/subminer assets\.tar\.gz\?sig='\\''abc'\\''' -o "\$tmp\/subminer-assets\.tar\.gz"/,
|
||||
);
|
||||
assert.match(
|
||||
command,
|
||||
/printf '%s %s\\n' 'abcdef1234' "\$tmp\/subminer-assets\.tar\.gz" \| sha256sum -c -/,
|
||||
);
|
||||
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/);
|
||||
assert.match(
|
||||
command,
|
||||
/sudo cp "\$tmp\/assets\/themes\/subminer\.rasi" '\/usr\/local\/share\/SubMiner'\\''s data'\/themes\/subminer\.rasi/,
|
||||
);
|
||||
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/plugin/);
|
||||
assert.match(command, /sudo rm -rf .*plugin\/subminer\.next/);
|
||||
assert.match(command, /sudo cp -R "\$tmp\/plugin\/subminer" .*plugin\/subminer\.next/);
|
||||
assert.match(command, /sudo mv .*plugin\/subminer\.next.*plugin\/subminer/);
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => {
|
||||
test('updateSupportAssetsFromRelease skips on non-Linux platforms', async () => {
|
||||
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||
try {
|
||||
const results = await runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
status: 'skipped',
|
||||
message: 'Support assets are only installed on Linux.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease skips when no managed support-asset roots exist', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
|
||||
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||
|
||||
try {
|
||||
const results = await updateSupportAssetsFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
assets: [
|
||||
{
|
||||
name: 'subminer-assets.tar.gz',
|
||||
browser_download_url: 'https://example.test/subminer-assets.tar.gz',
|
||||
},
|
||||
],
|
||||
},
|
||||
sha256Sums: new Map([['subminer-assets.tar.gz', sha256(archive)]]),
|
||||
downloadAsset: async () => archive,
|
||||
platform: 'linux',
|
||||
const results = await runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
xdgDataHome,
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [{ status: 'updated', path: dataDir }]);
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
status: 'skipped',
|
||||
message: 'No managed SubMiner support-asset install detected.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease skips existing data roots without managed asset markers', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'preferences.json'), '{}\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||
|
||||
try {
|
||||
const results = await runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
xdgDataHome,
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
status: 'skipped',
|
||||
message: 'No managed SubMiner support-asset install detected.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease installs missing plugin into a root with a managed theme marker', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||
extraPluginFiles: [{ relativePath: 'nested.lua', content: 'nested file\n' }],
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
xdgDataHome,
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'theme',
|
||||
path: dataDir,
|
||||
message: 'Updated theme.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'plugin',
|
||||
path: dataDir,
|
||||
message: 'Installed plugin.',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||
'new theme\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||
'old plugin\n',
|
||||
'new plugin\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/version.lua'), 'utf8'),
|
||||
'return {\n\tversion = "0.12.0",\n}\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/nested.lua'), 'utf8'),
|
||||
'nested file\n',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease preserves existing plugin when staged replacement copy fails', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
const pluginDir = path.join(dataDir, 'plugin/subminer');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n');
|
||||
fs.writeFileSync(path.join(pluginDir, 'main.lua'), 'old plugin\n');
|
||||
fs.writeFileSync(path.join(pluginDir, 'version.lua'), 'return {\n\tversion = "0.11.0",\n}\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||
themeContent: 'same theme\n',
|
||||
pluginVersion: '0.12.0',
|
||||
extraPluginFiles: [{ relativePath: 'blocked.lua', content: 'blocked\n' }],
|
||||
});
|
||||
|
||||
try {
|
||||
const originalCp = fs.promises.cp;
|
||||
fs.promises.cp = async (...args: Parameters<typeof fs.promises.cp>) => {
|
||||
const targetPath = String(args[1]);
|
||||
if (
|
||||
targetPath.endsWith(`${path.sep}subminer`) ||
|
||||
targetPath.endsWith(`${path.sep}subminer.next`)
|
||||
) {
|
||||
throw new Error('copy failed');
|
||||
}
|
||||
return originalCp(...args);
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
xdgDataHome,
|
||||
}),
|
||||
/copy failed/,
|
||||
);
|
||||
} finally {
|
||||
fs.promises.cp = originalCp;
|
||||
}
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(pluginDir, 'main.lua'), 'utf8'), 'old plugin\n');
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(pluginDir, 'version.lua'), 'utf8'),
|
||||
'return {\n\tversion = "0.11.0",\n}\n',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease reports both activation and rollback failures', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
const pluginDir = path.join(dataDir, 'plugin/subminer');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n');
|
||||
fs.writeFileSync(path.join(pluginDir, 'main.lua'), 'old plugin\n');
|
||||
fs.writeFileSync(path.join(pluginDir, 'version.lua'), 'return {\n\tversion = "0.11.0",\n}\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||
themeContent: 'same theme\n',
|
||||
pluginVersion: '0.12.0',
|
||||
});
|
||||
|
||||
try {
|
||||
const originalRename = fs.promises.rename;
|
||||
fs.promises.rename = async (...args: Parameters<typeof fs.promises.rename>) => {
|
||||
const sourcePath = String(args[0]);
|
||||
const targetPath = String(args[1]);
|
||||
if (sourcePath.endsWith(`${path.sep}subminer.next`)) {
|
||||
throw new Error('activate failed');
|
||||
}
|
||||
if (
|
||||
sourcePath.endsWith(`${path.sep}subminer.bak`) &&
|
||||
targetPath.endsWith(`${path.sep}subminer`)
|
||||
) {
|
||||
throw new Error('rollback failed');
|
||||
}
|
||||
return originalRename(...args);
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
xdgDataHome,
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof AggregateError &&
|
||||
/failed to activate staged plugin/i.test(error.message) &&
|
||||
error.errors.some((nested) => String(nested).includes('activate failed')) &&
|
||||
error.errors.some((nested) => String(nested).includes('rollback failed')),
|
||||
);
|
||||
} finally {
|
||||
fs.promises.rename = originalRename;
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n');
|
||||
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'same plugin\n');
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, 'plugin/subminer/version.lua'),
|
||||
'return {\n\tversion = "0.12.0",\n}\n',
|
||||
);
|
||||
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||
themeContent: 'same theme\n',
|
||||
pluginVersion: '0.12.0',
|
||||
pluginMainContent: 'release plugin differs but version matches\n',
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
xdgDataHome,
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
status: 'skipped',
|
||||
component: 'theme',
|
||||
path: dataDir,
|
||||
message: 'Theme already up to date.',
|
||||
},
|
||||
{
|
||||
status: 'skipped',
|
||||
component: 'plugin',
|
||||
path: dataDir,
|
||||
message: 'Plugin already up to date.',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||
'same theme\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||
'same plugin\n',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease updates changed theme and outdated plugin while removing stale plugin files', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
|
||||
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, 'plugin/subminer/version.lua'),
|
||||
'return {\n\tversion = "0.11.0",\n}\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/stale.lua'), 'stale\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||
themeContent: 'new theme\n',
|
||||
pluginVersion: '0.12.0',
|
||||
pluginMainContent: 'new plugin main\n',
|
||||
extraPluginFiles: [{ relativePath: 'fresh.lua', content: 'fresh\n' }],
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
xdgDataHome,
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'theme',
|
||||
path: dataDir,
|
||||
message: 'Updated theme.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'plugin',
|
||||
path: dataDir,
|
||||
message: 'Updated plugin.',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||
'new theme\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||
'new plugin main\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/fresh.lua'), 'utf8'),
|
||||
'fresh\n',
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(dataDir, 'plugin/subminer/stale.lua')), false);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease returns protected commands for managed roots that are not writable', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'managed theme\n');
|
||||
const originalMode = fs.statSync(dataDir).mode & 0o777;
|
||||
fs.chmodSync(dataDir, 0o555);
|
||||
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||
|
||||
try {
|
||||
const results = await runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
xdgDataHome,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
results.map((result) => ({
|
||||
status: result.status,
|
||||
component: result.component,
|
||||
path: result.path,
|
||||
command: typeof result.command === 'string',
|
||||
})),
|
||||
[
|
||||
{
|
||||
status: 'protected',
|
||||
component: 'theme',
|
||||
path: dataDir,
|
||||
command: true,
|
||||
},
|
||||
{
|
||||
status: 'protected',
|
||||
component: 'plugin',
|
||||
path: dataDir,
|
||||
command: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.match(results[0]?.command ?? '', /themes\/subminer\.rasi/);
|
||||
assert.match(results[0]?.command ?? '', /plugin\/subminer/);
|
||||
} finally {
|
||||
fs.chmodSync(dataDir, originalMode);
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease returns missing-asset when release plugin version metadata is absent', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'managed theme\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||
pluginVersion: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await runLinuxSupportAssetUpdate({
|
||||
archive,
|
||||
xdgDataHome,
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
status: 'missing-asset',
|
||||
message: 'Support asset archive has no readable plugin version metadata.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,12 +5,17 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { findReleaseAsset } from './release-assets';
|
||||
import { compareSemverLike, findReleaseAsset } from './release-assets';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const THEME_RELATIVE_PATH = path.join('themes', 'subminer.rasi');
|
||||
const PLUGIN_ENTRYPOINT_RELATIVE_PATH = path.join('plugin', 'subminer', 'main.lua');
|
||||
const PLUGIN_VERSION_RELATIVE_PATH = path.join('plugin', 'subminer', 'version.lua');
|
||||
const PLUGIN_DIR_RELATIVE_PATH = path.join('plugin', 'subminer');
|
||||
|
||||
export interface SupportAssetsUpdateResult {
|
||||
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
|
||||
component?: 'theme' | 'plugin';
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
@@ -24,6 +29,108 @@ function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function parsePluginVersion(content: string): string | null {
|
||||
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(targetPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async function canWrite(targetPath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(targetPath, fs.constants.W_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async function readFileIfExists(targetPath: string): Promise<Buffer | null> {
|
||||
try {
|
||||
return await fs.promises.readFile(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readInstalledPluginVersion(pluginDir: string): Promise<string | null> {
|
||||
try {
|
||||
return parsePluginVersion(
|
||||
await fs.promises.readFile(path.join(pluginDir, 'version.lua'), 'utf8'),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectManagedSupportAssetDataDirs(dataDirs: string[]): Promise<string[]> {
|
||||
const managedDataDirs: string[] = [];
|
||||
for (const dataDir of dataDirs) {
|
||||
const [hasTheme, hasPlugin] = await Promise.all([
|
||||
pathExists(path.join(dataDir, THEME_RELATIVE_PATH)),
|
||||
pathExists(path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH)),
|
||||
]);
|
||||
if (hasTheme || hasPlugin) {
|
||||
managedDataDirs.push(dataDir);
|
||||
}
|
||||
}
|
||||
return managedDataDirs;
|
||||
}
|
||||
|
||||
async function replacePluginDir(sourcePluginDir: string, targetPluginDir: string): Promise<void> {
|
||||
const parentDir = path.dirname(targetPluginDir);
|
||||
const stagedDir = `${targetPluginDir}.next`;
|
||||
const backupDir = `${targetPluginDir}.bak`;
|
||||
const targetExists = await pathExists(targetPluginDir);
|
||||
|
||||
await fs.promises.rm(stagedDir, { recursive: true, force: true });
|
||||
await fs.promises.rm(backupDir, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(parentDir, { recursive: true });
|
||||
await fs.promises.cp(sourcePluginDir, stagedDir, { recursive: true });
|
||||
|
||||
if (targetExists) {
|
||||
await fs.promises.rename(targetPluginDir, backupDir);
|
||||
}
|
||||
try {
|
||||
await fs.promises.rename(stagedDir, targetPluginDir);
|
||||
} catch (err) {
|
||||
if (targetExists) {
|
||||
try {
|
||||
await fs.promises.rename(backupDir, targetPluginDir);
|
||||
} catch (rollbackErr) {
|
||||
throw new AggregateError(
|
||||
[err, rollbackErr],
|
||||
'Failed to activate staged plugin and failed to restore previous plugin directory.',
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await fs.promises.rm(backupDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeSupportAssetResult(
|
||||
status: SupportAssetsUpdateResult['status'],
|
||||
component: SupportAssetsUpdateResult['component'],
|
||||
dataDir: string,
|
||||
message: string,
|
||||
command?: string,
|
||||
): SupportAssetsUpdateResult {
|
||||
const result: SupportAssetsUpdateResult = {
|
||||
status,
|
||||
component,
|
||||
path: dataDir,
|
||||
message,
|
||||
};
|
||||
if (command) {
|
||||
result.command = command;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function detectSupportAssetDataDirs(options: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
@@ -40,32 +147,33 @@ export function detectSupportAssetDataDirs(options: {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: string): string {
|
||||
export function buildProtectedSupportAssetsCommand(
|
||||
assetUrl: string,
|
||||
expectedSha256: string,
|
||||
dataDir: string,
|
||||
): string {
|
||||
const quotedDir = shellQuote(dataDir);
|
||||
const quotedPluginDir = shellQuote(path.posix.join(dataDir, 'plugin/subminer'));
|
||||
const quotedStagedPluginDir = shellQuote(path.posix.join(dataDir, 'plugin/subminer.next'));
|
||||
const quotedBackupPluginDir = shellQuote(path.posix.join(dataDir, 'plugin/subminer.bak'));
|
||||
const quotedExpectedSha256 = shellQuote(expectedSha256.toLowerCase());
|
||||
return [
|
||||
'tmp=$(mktemp -d)',
|
||||
'trap \'rm -rf "$tmp"\' EXIT',
|
||||
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
|
||||
`printf '%s %s\\n' ${quotedExpectedSha256} "$tmp/subminer-assets.tar.gz" | sha256sum -c -`,
|
||||
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
|
||||
`sudo mkdir -p ${quotedDir}/themes`,
|
||||
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
||||
`sudo mkdir -p ${quotedDir}/plugin`,
|
||||
`sudo rm -rf ${quotedStagedPluginDir} ${quotedBackupPluginDir}`,
|
||||
`sudo cp -R "$tmp/plugin/subminer" ${quotedStagedPluginDir}`,
|
||||
`[ ! -e ${quotedPluginDir} ] || sudo mv ${quotedPluginDir} ${quotedBackupPluginDir}`,
|
||||
`sudo mv ${quotedStagedPluginDir} ${quotedPluginDir}`,
|
||||
`sudo rm -rf ${quotedBackupPluginDir}`,
|
||||
].join(' && ');
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(targetPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async function canWrite(targetPath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(targetPath, fs.constants.W_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
export async function updateSupportAssetsFromRelease(options: {
|
||||
release: GitHubRelease | null;
|
||||
sha256Sums: Map<string, string>;
|
||||
@@ -79,10 +187,12 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
}
|
||||
if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }];
|
||||
const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz');
|
||||
if (!asset) return [{ status: 'missing-asset', message: 'Release has no rofi theme asset.' }];
|
||||
if (!asset) {
|
||||
return [{ status: 'missing-asset', message: 'Release has no support asset archive.' }];
|
||||
}
|
||||
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
|
||||
if (!expectedSha256) {
|
||||
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no rofi theme entry.' }];
|
||||
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support asset entry.' }];
|
||||
}
|
||||
|
||||
const dataDirs = detectSupportAssetDataDirs({
|
||||
@@ -90,41 +200,69 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
homeDir: options.homeDir ?? os.homedir(),
|
||||
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
|
||||
});
|
||||
const existingDataDirs: string[] = [];
|
||||
for (const dataDir of dataDirs) {
|
||||
const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi'));
|
||||
if (hasTheme) existingDataDirs.push(dataDir);
|
||||
}
|
||||
if (existingDataDirs.length === 0) {
|
||||
return [{ status: 'skipped', message: 'No existing rofi theme install detected.' }];
|
||||
const managedDataDirs = await detectManagedSupportAssetDataDirs(dataDirs);
|
||||
if (managedDataDirs.length === 0) {
|
||||
return [{ status: 'skipped', message: 'No managed SubMiner support-asset install detected.' }];
|
||||
}
|
||||
|
||||
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
|
||||
.filter((dataDir) => !fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory())
|
||||
.map((dataDir) => ({
|
||||
status: 'skipped' as const,
|
||||
path: dataDir,
|
||||
message: 'Support asset path is not a directory.',
|
||||
}));
|
||||
const results: SupportAssetsUpdateResult[] = [];
|
||||
const writableDataDirs: string[] = [];
|
||||
for (const dataDir of existingDataDirs) {
|
||||
for (const dataDir of managedDataDirs) {
|
||||
if (!fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory()) {
|
||||
results.push(
|
||||
makeSupportAssetResult(
|
||||
'skipped',
|
||||
'theme',
|
||||
dataDir,
|
||||
'Support asset path is not a directory.',
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'skipped',
|
||||
'plugin',
|
||||
dataDir,
|
||||
'Support asset path is not a directory.',
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await canWrite(dataDir)) {
|
||||
writableDataDirs.push(dataDir);
|
||||
} else {
|
||||
protectedResults.push({
|
||||
status: 'protected',
|
||||
path: dataDir,
|
||||
command: buildProtectedSupportAssetsCommand(asset.browser_download_url, dataDir),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const command = buildProtectedSupportAssetsCommand(
|
||||
asset.browser_download_url,
|
||||
expectedSha256,
|
||||
dataDir,
|
||||
);
|
||||
results.push(
|
||||
makeSupportAssetResult(
|
||||
'protected',
|
||||
'theme',
|
||||
dataDir,
|
||||
'Theme install requires a manual command.',
|
||||
command,
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'protected',
|
||||
'plugin',
|
||||
dataDir,
|
||||
'Plugin install requires a manual command.',
|
||||
command,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (writableDataDirs.length === 0) {
|
||||
return results;
|
||||
}
|
||||
if (writableDataDirs.length === 0) return protectedResults;
|
||||
|
||||
const archive = await options.downloadAsset(asset.browser_download_url);
|
||||
const actualSha256 = sha256(archive);
|
||||
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
||||
return [
|
||||
...protectedResults,
|
||||
...results,
|
||||
{
|
||||
status: 'hash-mismatch',
|
||||
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
|
||||
@@ -137,17 +275,85 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
const archivePath = path.join(tempDir, 'subminer-assets.tar.gz');
|
||||
await fs.promises.writeFile(archivePath, archive);
|
||||
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
|
||||
const results: SupportAssetsUpdateResult[] = [...protectedResults];
|
||||
|
||||
const themeSourcePath = path.join(tempDir, 'assets/themes/subminer.rasi');
|
||||
if (!(await pathExists(themeSourcePath))) {
|
||||
return [
|
||||
{ status: 'missing-asset', message: 'Support asset archive is missing the rofi theme.' },
|
||||
];
|
||||
}
|
||||
const themeBytes = await fs.promises.readFile(themeSourcePath);
|
||||
|
||||
const sourcePluginDir = path.join(tempDir, PLUGIN_DIR_RELATIVE_PATH);
|
||||
const sourcePluginEntrypoint = path.join(tempDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
|
||||
if (!(await pathExists(sourcePluginEntrypoint))) {
|
||||
return [
|
||||
{
|
||||
status: 'missing-asset',
|
||||
message: 'Support asset archive is missing the runtime plugin.',
|
||||
},
|
||||
];
|
||||
}
|
||||
const sourcePluginVersion = parsePluginVersion(
|
||||
await fs.promises
|
||||
.readFile(path.join(tempDir, PLUGIN_VERSION_RELATIVE_PATH), 'utf8')
|
||||
.catch(() => ''),
|
||||
);
|
||||
if (!sourcePluginVersion) {
|
||||
return [
|
||||
{
|
||||
status: 'missing-asset',
|
||||
message: 'Support asset archive has no readable plugin version metadata.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
for (const dataDir of writableDataDirs) {
|
||||
const targetThemePath = path.join(dataDir, 'themes/subminer.rasi');
|
||||
if (await pathExists(targetThemePath)) {
|
||||
await fs.promises.copyFile(
|
||||
path.join(tempDir, 'assets/themes/subminer.rasi'),
|
||||
targetThemePath,
|
||||
const targetThemePath = path.join(dataDir, THEME_RELATIVE_PATH);
|
||||
const existingThemeBytes = await readFileIfExists(targetThemePath);
|
||||
if (existingThemeBytes && Buffer.compare(existingThemeBytes, themeBytes) === 0) {
|
||||
results.push(
|
||||
makeSupportAssetResult('skipped', 'theme', dataDir, 'Theme already up to date.'),
|
||||
);
|
||||
} else {
|
||||
await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true });
|
||||
await fs.promises.writeFile(targetThemePath, themeBytes);
|
||||
results.push(
|
||||
makeSupportAssetResult(
|
||||
'updated',
|
||||
'theme',
|
||||
dataDir,
|
||||
existingThemeBytes ? 'Updated theme.' : 'Installed theme.',
|
||||
),
|
||||
);
|
||||
}
|
||||
results.push({ status: 'updated', path: dataDir });
|
||||
|
||||
const targetPluginDir = path.join(dataDir, PLUGIN_DIR_RELATIVE_PATH);
|
||||
const targetPluginEntrypoint = path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
|
||||
const installedPluginVersion = await readInstalledPluginVersion(targetPluginDir);
|
||||
const installedEntrypointExists = await pathExists(targetPluginEntrypoint);
|
||||
const shouldInstallPlugin =
|
||||
!installedEntrypointExists ||
|
||||
!installedPluginVersion ||
|
||||
compareSemverLike(sourcePluginVersion, installedPluginVersion) > 0;
|
||||
if (!shouldInstallPlugin) {
|
||||
results.push(
|
||||
makeSupportAssetResult('skipped', 'plugin', dataDir, 'Plugin already up to date.'),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await replacePluginDir(sourcePluginDir, targetPluginDir);
|
||||
results.push(
|
||||
makeSupportAssetResult(
|
||||
'updated',
|
||||
'plugin',
|
||||
dataDir,
|
||||
installedEntrypointExists ? 'Updated plugin.' : 'Installed plugin.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user