Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
564 changes: 549 additions & 15 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,10 @@
"cordova-plugin-system": "file:src/plugins/system",
"cordova-plugin-websocket": "file:src/plugins/websocket",
"css-loader": "^7.1.2",
"esbuild": "^0.25.10",
"mini-css-extract-plugin": "^2.9.3",
"path-browserify": "^1.0.1",
"postcss": "^8.4.38",
"postcss-loader": "^8.1.1",
"prettier": "^3.6.2",
"prettier-plugin-java": "^2.7.4",
Expand Down
2 changes: 1 addition & 1 deletion src/components/quickTools/footer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import settings from "lib/settings";
import items, { ref } from "./items";
import items from "./items";

/**
* Create a row with common buttons
Expand Down
243 changes: 145 additions & 98 deletions src/lib/acode.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import EditorFile from "lib/editorFile";
import files from "lib/fileList";
import fileTypeHandler from "lib/fileTypeHandler";
import fonts from "lib/fonts";
import {
LOADED_PLUGINS,
onPluginLoadCallback,
onPluginsLoadCompleteCallback,
} from "lib/loadPlugins";
import NotificationManager from "lib/notificationManager";
import openFolder, { addedFolder } from "lib/openFolder";
import projects from "lib/projects";
Expand Down Expand Up @@ -65,6 +70,23 @@ export default class Acode {
},
},
];
#pluginWatchers = {};

/**
* Clear a plugin's broken mark (so it can be retried)
* @param {string} pluginId
*/
clearBrokenPluginMark(pluginId) {
try {
const broken = appSettings.value.pluginsBroken || {};
if (broken[pluginId]) {
delete broken[pluginId];
appSettings.update(false);
}
} catch (e) {
console.warn("Failed to clear broken plugin mark:", e);
}
}

constructor() {
const encodingsModule = {
Expand Down Expand Up @@ -288,119 +310,144 @@ export default class Acode {
*/
installPlugin(pluginId, installerPluginName) {
return new Promise((resolve, reject) => {
confirm(
strings.install,
`Do you want to install plugin '${pluginId}'${installerPluginName ? ` requested by ${installerPluginName}` : ""}?`,
)
.then((confirmation) => {
if (!confirmation) {
reject(new Error("User cancelled installation"));
fsOperation(Url.join(PLUGIN_DIR, pluginId))
.exists()
.then((isPluginExists) => {
if (isPluginExists) {
reject(new Error("Plugin already installed"));
return;
}

fsOperation(Url.join(PLUGIN_DIR, pluginId))
.exists()
.then((isPluginExists) => {
if (isPluginExists) {
reject(new Error("Plugin already installed"));
return;
}

let purchaseToken;
let product;
const pluginUrl = Url.join(
constants.API_BASE,
`plugin/${pluginId}`,
);
fsOperation(pluginUrl)
.readFile("json")
.catch(() => {
reject(new Error("Failed to fetch plugin details"));
return null;
})
.then((remotePlugin) => {
if (remotePlugin) {
const isPaid = remotePlugin.price > 0;
helpers
.promisify(iap.getProducts, [remotePlugin.sku])
.then((products) => {
[product] = products;
if (product) {
return getPurchase(product.productId);
}
return null;
})
.then((purchase) => {
purchaseToken = purchase?.purchaseToken;

if (isPaid && !purchaseToken) {
if (!product) throw new Error("Product not found");
return helpers.checkAPIStatus().then((apiStatus) => {
if (!apiStatus) {
alert(strings.error, strings.api_error);
return;
}

iap.setPurchaseUpdatedListener(
...purchaseListener(onpurchase, onerror),
);
return helpers.promisify(
iap.purchase,
product.productId,
);
confirm(
strings.install,
`Do you want to install plugin '${pluginId}'${installerPluginName ? ` requested by ${installerPluginName}` : ""}?`,
).then((confirmation) => {
if (!confirmation) {
reject(new Error("User cancelled installation"));
return;
}

let purchaseToken;
let product;
const pluginUrl = Url.join(
constants.API_BASE,
`plugin/${pluginId}`,
);
fsOperation(pluginUrl)
.readFile("json")
.catch(() => {
reject(new Error("Failed to fetch plugin details"));
return null;
})
.then((remotePlugin) => {
if (remotePlugin) {
const isPaid = remotePlugin.price > 0;
helpers
.promisify(iap.getProducts, [remotePlugin.sku])
.then((products) => {
[product] = products;
if (product) {
return getPurchase(product.productId);
}
return null;
})
.then((purchase) => {
purchaseToken = purchase?.purchaseToken;

if (isPaid && !purchaseToken) {
if (!product) throw new Error("Product not found");
return helpers.checkAPIStatus().then((apiStatus) => {
if (!apiStatus) {
alert(strings.error, strings.api_error);
return;
}

iap.setPurchaseUpdatedListener(
...purchaseListener(onpurchase, onerror),
);
return helpers.promisify(
iap.purchase,
product.productId,
);
});
}
})
.then(() => {
import("lib/installPlugin").then(
({ default: installPlugin }) => {
installPlugin(
pluginId,
remotePlugin.name,
purchaseToken,
).then(() => {
resolve();
});
}
})
.then(() => {
import("lib/installPlugin").then(
({ default: installPlugin }) => {
installPlugin(
pluginId,
remotePlugin.name,
purchaseToken,
).then(() => {
resolve();
});
},
);
});

async function onpurchase(e) {
const purchase = await getPurchase(product.productId);
await ajax.post(
Url.join(constants.API_BASE, "plugin/order"),
{
data: {
id: remotePlugin.id,
token: purchase?.purchaseToken,
package: BuildInfo.packageName,
},
},
);
purchaseToken = purchase?.purchaseToken;
}
});

async function onpurchase(e) {
const purchase = await getPurchase(product.productId);
await ajax.post(
Url.join(constants.API_BASE, "plugin/order"),
{
data: {
id: remotePlugin.id,
token: purchase?.purchaseToken,
package: BuildInfo.packageName,
},
},
);
purchaseToken = purchase?.purchaseToken;
}

async function onerror(error) {
throw error;
}
async function onerror(error) {
throw error;
}
});

async function getPurchase(sku) {
const purchases = await helpers.promisify(iap.getPurchases);
const purchase = purchases.find((p) =>
p.productIds.includes(sku),
);
return purchase;
}
});
}
});

async function getPurchase(sku) {
const purchases = await helpers.promisify(iap.getPurchases);
const purchase = purchases.find((p) =>
p.productIds.includes(sku),
);
return purchase;
}
});
})
.catch((error) => {
reject(error);
});
});
}

[onPluginLoadCallback](pluginId) {
if (this.#pluginWatchers[pluginId]) {
this.#pluginWatchers[pluginId].resolve();
delete this.#pluginWatchers[pluginId];
}
}

[onPluginsLoadCompleteCallback]() {
for (const key in this.#pluginWatchers) {
this.#pluginWatchers[key].reject();
}
}
Comment on lines +432 to +436
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method correctly rejects promises for plugins that didn't load. However, there are a couple of improvements that could be made:

  1. It's good practice to reject promises with an Error object to provide more context for debugging.
  2. The #pluginWatchers object is not cleared after rejecting the promises. This could lead to a memory leak, as watchers for failed plugins will persist.

I suggest modifying this method to include an error message and to clear the watchers.

	[onPluginsLoadCompleteCallback]() {
		for (const pluginId in this.#pluginWatchers) {
			this.#pluginWatchers[pluginId].reject(
				new Error(`Plugin '${pluginId}' failed to load.`),
			);
		}
		this.#pluginWatchers = {};
	}


waitForPlugin(pluginId) {
return new Promise((resolve, reject) => {
if (LOADED_PLUGINS.has(pluginId)) {
return resolve(true);
}

this.#pluginWatchers[pluginId] = {
resolve,
reject,
};
});
}

get exitAppMessage() {
const numFiles = editorManager.hasUnsavedFiles();
if (numFiles) {
Expand Down
51 changes: 45 additions & 6 deletions src/lib/loadPlugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ const THEME_IDENTIFIERS = new Set([
"acode.plugin.extra_syntax_highlights",
]);

export const onPluginLoadCallback = Symbol("onPluginLoadCallback");
export const onPluginsLoadCompleteCallback = Symbol(
"onPluginsLoadCompleteCallback",
);

export const LOADED_PLUGINS = new Set();
export const BROKEN_PLUGINS = new Map();

export default async function loadPlugins(loadOnlyTheme = false) {
const plugins = await fsOperation(PLUGIN_DIR).lsDir();
const results = [];
const failedPlugins = [];
const loadedPlugins = new Set();

if (plugins.length > 0) {
toast(strings["loading plugins"]);
Expand All @@ -42,21 +49,29 @@ export default async function loadPlugins(loadOnlyTheme = false) {
// Only load theme plugins matching current theme
pluginsToLoad = plugins.filter((pluginDir) => {
const pluginId = Url.basename(pluginDir.url);
return isThemePlugin(pluginId) && !loadedPlugins.has(pluginId);
// Skip already loaded and plugins that were previously marked broken
return (
isThemePlugin(pluginId) &&
!LOADED_PLUGINS.has(pluginId) &&
!BROKEN_PLUGINS.has(pluginId)
);
});
} else {
// Load non-theme plugins that aren't loaded yet and are enabled
pluginsToLoad = plugins.filter((pluginDir) => {
const pluginId = Url.basename(pluginDir.url);
// Skip theme plugins, already loaded, disabled or previously marked broken
return (
!isThemePlugin(pluginId) &&
!loadedPlugins.has(pluginId) &&
enabledMap[pluginId] !== true
!LOADED_PLUGINS.has(pluginId) &&
enabledMap[pluginId] !== true &&
!BROKEN_PLUGINS.has(pluginId)
);
});
}

// Load plugins concurrently
const LOAD_TIMEOUT = 15000; // ms per plugin
const loadPromises = pluginsToLoad.map(async (pluginDir) => {
const pluginId = Url.basename(pluginDir.url);

Expand All @@ -73,18 +88,42 @@ export default async function loadPlugins(loadOnlyTheme = false) {
}

try {
await loadPlugin(pluginId);
loadedPlugins.add(pluginId);
// ensure loadPlugin doesn't hang: timeout wrapper
await Promise.race([
loadPlugin(pluginId),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("Plugin load timeout")), LOAD_TIMEOUT),
),
]);
LOADED_PLUGINS.add(pluginId);

acode[onPluginLoadCallback](pluginId);

results.push(true);
// clear broken mark if present
if (BROKEN_PLUGINS.has(pluginId)) {
BROKEN_PLUGINS.delete(pluginId);
}
} catch (error) {
console.error(`Error loading plugin ${pluginId}:`, error);
// mark plugin as broken to avoid repeated attempts until user intervenes
try {
BROKEN_PLUGINS.set(pluginId, {
error: String(error.message || error),
timestamp: Date.now(),
});
} catch (e) {
console.warn("Failed to mark plugin as broken:", e);
}
failedPlugins.push(pluginId);
results.push(false);
}
});

await Promise.allSettled(loadPromises);

acode[onPluginsLoadCompleteCallback]();

if (failedPlugins.length > 0) {
setTimeout(() => {
cleanupFailedPlugins(failedPlugins).catch((error) => {
Expand Down
Loading