diff --git a/src/components/sidebar/index.js b/src/components/sidebar/index.js
index 7920facfb..fc8b5015c 100644
--- a/src/components/sidebar/index.js
+++ b/src/components/sidebar/index.js
@@ -216,7 +216,7 @@ function create($container, $toggler) {
return;
}
- defaultAvatar.classList.add("loading");
+ defaultAvatar.classList.add("avatar-loading");
const img =
;
const avatarFile = await getUserAvatar(user);
diff --git a/src/components/sidebar/style.scss b/src/components/sidebar/style.scss
index 6b3e76763..55bacb5ec 100644
--- a/src/components/sidebar/style.scss
+++ b/src/components/sidebar/style.scss
@@ -132,6 +132,17 @@ body.no-animation {
&.active {
opacity: 1;
}
+
+ &.avatar-loading {
+ opacity: 0.45;
+ background-color: rgba(255, 255, 255, 0.08);
+ animation: sidebar-avatar-loading 1.2s ease-in-out infinite;
+
+ &:hover {
+ opacity: 0.45;
+ background-color: rgba(255, 255, 255, 0.08);
+ }
+ }
}
}
@@ -340,6 +351,17 @@ body.no-animation {
}
}
+@keyframes sidebar-avatar-loading {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(0.94);
+ }
+}
+
.user-menu {
position: absolute;
bottom: 55px;
diff --git a/src/handlers/intent.js b/src/handlers/intent.js
index 5e7335b61..a929b46cf 100644
--- a/src/handlers/intent.js
+++ b/src/handlers/intent.js
@@ -28,6 +28,10 @@ export default async function HandleIntent(intent = {}) {
const path = url.replace("acode://", "");
const [module, action, value] = path.split("/");
+ if (module === "auth" && action === "callback") {
+ return;
+ }
+
let defaultPrevented = false;
const event = new IntentEvent(module, action, value);
for (const handler of handlers) {
diff --git a/src/lib/auth.js b/src/lib/auth.js
index 0e1594778..cf4fa33c9 100644
--- a/src/lib/auth.js
+++ b/src/lib/auth.js
@@ -1,7 +1,4 @@
-import toast from "components/toast";
-import { addIntentHandler } from "handlers/intent";
import config from "./config";
-import customTab from "./customTab";
/**
* @typedef {object} User
@@ -29,6 +26,7 @@ let loggedInUser = null;
let cacheTimeout = null;
const CACHE_USER_KEY = "cached-logged-in-user";
+const LOGIN_RESUME_TIMEOUT_MS = 60_000;
const loginEvents = {
listeners: new Set(),
@@ -50,7 +48,6 @@ class AuthService {
#loginTimeout = null;
constructor() {
- addIntentHandler(this.onIntentReceiver.bind(this));
loginEvents.addListener(() => {
clearTimeout(this.#loginTimeout);
for (const callback of this.#loginCallbacks) {
@@ -66,29 +63,10 @@ class AuthService {
}
this.#loginCallbacks.clear();
- }, 1000);
+ }, LOGIN_RESUME_TIMEOUT_MS);
});
}
- async onIntentReceiver(event) {
- try {
- if (event?.module === "user" && event?.action === "login") {
- if (event?.value) {
- this.#exec("saveToken", [event.value]);
- toast("Logged in successfully");
-
- setTimeout(() => {
- loginEvents.emit();
- }, 500);
- }
- }
- return null;
- } catch (error) {
- console.error("Failed to parse intent token.", error);
- return null;
- }
- }
-
/**
* Helper to wrap cordova.exec in a Promise
*/
@@ -159,12 +137,22 @@ class AuthService {
async login() {
return new Promise((resolve, reject) => {
- customTab(`${config.BASE_URL}/login?redirect=app`).catch((err) => {
- console.error("Custom tab error", err);
- reject("Failed to open browser");
- });
-
- this.#loginCallbacks.add({ resolve, reject });
+ const callback = { resolve, reject };
+ this.#loginCallbacks.add(callback);
+ this.#exec("login", [
+ {
+ baseUrl: config.BASE_URL,
+ appVersionCode: window.BuildInfo?.versionCode || 0,
+ },
+ ])
+ .then(() => {
+ loginEvents.emit();
+ })
+ .catch((err) => {
+ console.error("Native login error", err);
+ this.#loginCallbacks.delete(callback);
+ reject("Failed to login");
+ });
});
}
}
diff --git a/src/lib/checkPluginsUpdate.js b/src/lib/checkPluginsUpdate.js
index 58d89dc14..4c8a6a6b1 100644
--- a/src/lib/checkPluginsUpdate.js
+++ b/src/lib/checkPluginsUpdate.js
@@ -1,5 +1,6 @@
import fsOperation from "fileSystem";
import Url from "utils/Url";
+import { isVersionGreater } from "utils/version";
import config from "./config";
export default async function checkPluginsUpdate() {
@@ -20,7 +21,26 @@ export default async function checkPluginsUpdate() {
if (res.ok) {
const json = await res.json();
- if (json.update) {
+ if (!json.update) return;
+
+ if (json.version) {
+ if (isVersionGreater(json.version, plugin.version)) {
+ updates.push(plugin.id);
+ }
+ return;
+ }
+
+ const remotePlugin = await fsOperation(
+ config.API_BASE,
+ `plugin/${plugin.id}`,
+ )
+ .readFile("json")
+ .catch(() => null);
+
+ if (
+ !remotePlugin?.version ||
+ isVersionGreater(remotePlugin.version, plugin.version)
+ ) {
updates.push(plugin.id);
}
}
diff --git a/src/lib/installPlugin.js b/src/lib/installPlugin.js
index b7de3508d..9dd31ab78 100644
--- a/src/lib/installPlugin.js
+++ b/src/lib/installPlugin.js
@@ -6,6 +6,7 @@ import purchaseListener from "handlers/purchase";
import JSZip from "jszip";
import helpers from "utils/helpers";
import Url from "utils/Url";
+import { isVersionGreater } from "utils/version";
import config from "./config";
import InstallState from "./installState";
import { loadPluginWithTimeout } from "./loadPlugins";
@@ -406,7 +407,8 @@ async function resolveDepsManifest(deps) {
throw new Error(`Unknown plugin dependency: ${dependency}`);
const version = await getInstalledPluginVersion(remoteDependency.id);
- if (remoteDependency?.version === version) continue;
+ if (version && !isVersionGreater(remoteDependency?.version, version))
+ continue;
if (remoteDependency.dependencies) {
const manifests = await resolveDepsManifest(
diff --git a/src/pages/plugin/plugin.js b/src/pages/plugin/plugin.js
index 7106a4e5e..4788e0b1c 100644
--- a/src/pages/plugin/plugin.js
+++ b/src/pages/plugin/plugin.js
@@ -21,6 +21,7 @@ import markdownItTaskLists from "markdown-it-task-lists";
import { highlightCodeBlock, initHighlighting } from "utils/codeHighlight";
import helpers from "utils/helpers";
import Url from "utils/Url";
+import { isVersionGreater } from "utils/version";
import view, { cleanups } from "./plugin.view.js";
let $lastPluginPage;
@@ -160,7 +161,10 @@ export default async function PluginInclude(
if (cancelled || !remotePlugin) return;
- if (installed && remotePlugin?.version !== plugin.version) {
+ if (
+ installed &&
+ isVersionGreater(remotePlugin?.version, plugin.version)
+ ) {
currentVersion = plugin.version;
update = true;
}
diff --git a/src/plugins/auth/plugin.xml b/src/plugins/auth/plugin.xml
index 521b422a3..f8ed0e675 100644
--- a/src/plugins/auth/plugin.xml
+++ b/src/plugins/auth/plugin.xml
@@ -12,10 +12,11 @@
+
-
\ No newline at end of file
+
diff --git a/src/plugins/auth/src/android/Authenticator.java b/src/plugins/auth/src/android/Authenticator.java
index f2df9768f..54f754415 100644
--- a/src/plugins/auth/src/android/Authenticator.java
+++ b/src/plugins/auth/src/android/Authenticator.java
@@ -1,12 +1,25 @@
package com.foxdebug.acode.rk.auth;
+import android.content.Intent;
+import android.net.Uri;
import android.util.Log;
import android.webkit.CookieManager;
import android.webkit.WebView;
+import androidx.browser.customtabs.CustomTabsIntent;
import com.foxdebug.acode.rk.auth.EncryptedPreferenceManager;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
import org.apache.cordova.*;
import org.json.JSONArray;
import org.json.JSONException;
+import org.json.JSONObject;
public class Authenticator extends CordovaPlugin {
private static final String TAG = "AcodeAuth";
@@ -14,6 +27,11 @@ public class Authenticator extends CordovaPlugin {
private static final String KEY_TOKEN = "auth_token";
private static final String PRO_PURCHASED = "pro_purchased";
private static final String KEY_MIGRATED_V2 = "migrated_host_to_domain_cookies";
+ private static final String KEY_PENDING_STATE = "pending_login_state";
+ private static final String KEY_PENDING_VERIFIER = "pending_login_verifier";
+ private static final String KEY_PENDING_BASE_URL = "pending_login_base_url";
+ private static final int AUTH_CONNECT_TIMEOUT_MS = 15_000;
+ private static final int AUTH_READ_TIMEOUT_MS = 30_000;
private static final String[] API_ORIGINS = {
"https://acode.app"
};
@@ -22,6 +40,8 @@ public class Authenticator extends CordovaPlugin {
"https://dev.acode.app"
};
private EncryptedPreferenceManager prefManager;
+ private final Object loginCallbackLock = new Object();
+ private volatile CallbackContext loginCallback;
@Override
protected void pluginInitialize() {
@@ -41,6 +61,8 @@ protected void pluginInitialize() {
if (!token.isEmpty()) {
setTokenCookie(token);
}
+
+ handleAuthCallback(cordova.getActivity().getIntent());
}
@Override
@@ -60,12 +82,209 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo
cordova.getActivity().runOnUiThread(() -> setTokenCookie(token));
callbackContext.success();
return true;
+ case "login":
+ JSONObject options = args.optJSONObject(0);
+ startLogin(options != null ? options : new JSONObject(), callbackContext);
+ return true;
default:
Log.w(TAG, "Attempted to call unknown action: " + action);
return false;
}
}
+ @Override
+ public void onNewIntent(Intent intent) {
+ if (!handleAuthCallback(intent)) {
+ super.onNewIntent(intent);
+ }
+ }
+
+ private void startLogin(JSONObject options, CallbackContext callbackContext) {
+ String baseUrl = options.optString("baseUrl", "https://acode.app");
+ int appVersionCode = options.optInt("appVersionCode", 0);
+ String state = randomHex(24);
+ String verifier = randomHex(32);
+ String challenge = sha256Hex(verifier);
+
+ prefManager.setString(KEY_PENDING_STATE, state);
+ prefManager.setString(KEY_PENDING_VERIFIER, verifier);
+ prefManager.setString(KEY_PENDING_BASE_URL, baseUrl);
+ setLoginCallback(callbackContext);
+
+ Uri loginUri = Uri.parse(baseUrl)
+ .buildUpon()
+ .appendEncodedPath("login")
+ .appendQueryParameter("redirect", "app")
+ .appendQueryParameter("authFlow", "app-code")
+ .appendQueryParameter("state", state)
+ .appendQueryParameter("challenge", challenge)
+ .appendQueryParameter("appVersionCode", String.valueOf(appVersionCode))
+ .build();
+
+ cordova.getActivity().runOnUiThread(() -> {
+ try {
+ CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder().build();
+ customTabsIntent.intent.putExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.SHOW_PAGE_TITLE);
+ customTabsIntent.launchUrl(cordova.getActivity(), loginUri);
+
+ PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT);
+ result.setKeepCallback(true);
+ callbackContext.sendPluginResult(result);
+ } catch (Exception error) {
+ Intent fallback = new Intent(Intent.ACTION_VIEW, loginUri);
+ try {
+ cordova.getActivity().startActivity(fallback);
+ PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT);
+ result.setKeepCallback(true);
+ callbackContext.sendPluginResult(result);
+ } catch (Exception fallbackError) {
+ failLogin(fallbackError.getMessage());
+ }
+ }
+ });
+ }
+
+ private boolean handleAuthCallback(Intent intent) {
+ Uri data = intent != null ? intent.getData() : null;
+ if (data == null || !"acode".equals(data.getScheme()) || !"auth".equals(data.getHost()) || !"/callback".equals(data.getPath())) {
+ return false;
+ }
+
+ String code = data.getQueryParameter("code");
+ String state = data.getQueryParameter("state");
+ String expectedState = prefManager.getString(KEY_PENDING_STATE, "");
+ String verifier = prefManager.getString(KEY_PENDING_VERIFIER, "");
+ String baseUrl = prefManager.getString(KEY_PENDING_BASE_URL, "https://acode.app");
+
+ if (code == null || state == null || expectedState.isEmpty() || verifier.isEmpty() || !expectedState.equals(state)) {
+ failLogin("Invalid login callback");
+ return true;
+ }
+
+ cordova.getThreadPool().execute(() -> {
+ try {
+ String token = exchangeCode(baseUrl, code, state, verifier);
+ prefManager.setString(KEY_TOKEN, token);
+ prefManager.remove(KEY_PENDING_STATE);
+ prefManager.remove(KEY_PENDING_VERIFIER);
+ prefManager.remove(KEY_PENDING_BASE_URL);
+ cordova.getActivity().runOnUiThread(() -> setTokenCookie(token));
+ CallbackContext callback = takeLoginCallback();
+ if (callback != null) {
+ callback.success();
+ }
+ } catch (Exception error) {
+ Log.e(TAG, "Failed to exchange app auth code", error);
+ failLogin("Unable to complete login");
+ }
+ });
+
+ return true;
+ }
+
+ private String exchangeCode(String baseUrl, String code, String state, String verifier) throws Exception {
+ URL url = new URL(baseUrl + "/api/user/app-token/exchange");
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ try {
+ connection.setConnectTimeout(AUTH_CONNECT_TIMEOUT_MS);
+ connection.setReadTimeout(AUTH_READ_TIMEOUT_MS);
+ connection.setRequestMethod("POST");
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setDoOutput(true);
+
+ JSONObject body = new JSONObject();
+ body.put("code", code);
+ body.put("state", state);
+ body.put("verifier", verifier);
+
+ try (OutputStream os = connection.getOutputStream()) {
+ os.write(body.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ int status = connection.getResponseCode();
+ InputStream stream = status >= 200 && status < 300 ? connection.getInputStream() : connection.getErrorStream();
+ String response = readStream(stream);
+
+ if (status < 200 || status >= 300) {
+ throw new IllegalStateException(response);
+ }
+
+ JSONObject json = new JSONObject(response);
+ String token = json.optString("token", "");
+ if (token.isEmpty()) {
+ throw new IllegalStateException("Missing token");
+ }
+
+ return token;
+ } finally {
+ connection.disconnect();
+ }
+ }
+
+ private String readStream(InputStream stream) throws Exception {
+ if (stream == null) return "";
+ StringBuilder builder = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ builder.append(line);
+ }
+ }
+ return builder.toString();
+ }
+
+ private void failLogin(String message) {
+ prefManager.remove(KEY_PENDING_STATE);
+ prefManager.remove(KEY_PENDING_VERIFIER);
+ prefManager.remove(KEY_PENDING_BASE_URL);
+ CallbackContext callback = takeLoginCallback();
+ if (callback != null) {
+ callback.error(message);
+ }
+ }
+
+ private void setLoginCallback(CallbackContext callbackContext) {
+ CallbackContext previousCallback = null;
+ synchronized (loginCallbackLock) {
+ previousCallback = loginCallback;
+ loginCallback = callbackContext;
+ }
+ if (previousCallback != null) {
+ previousCallback.error("Login cancelled");
+ }
+ }
+
+ private CallbackContext takeLoginCallback() {
+ synchronized (loginCallbackLock) {
+ CallbackContext callback = loginCallback;
+ loginCallback = null;
+ return callback;
+ }
+ }
+
+ private String randomHex(int byteCount) {
+ byte[] bytes = new byte[byteCount];
+ new SecureRandom().nextBytes(bytes);
+ StringBuilder builder = new StringBuilder(byteCount * 2);
+ for (byte b : bytes) {
+ builder.append(String.format("%02x", b));
+ }
+ return builder.toString();
+ }
+
+ private String sha256Hex(String value) {
+ try {
+ byte[] hash = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
+ StringBuilder builder = new StringBuilder(hash.length * 2);
+ for (byte b : hash) {
+ builder.append(String.format("%02x", b));
+ }
+ return builder.toString();
+ } catch (Exception error) {
+ throw new IllegalStateException("Unable to create auth challenge", error);
+ }
+ }
+
private void setTokenCookie(String token) {
CookieManager cm = CookieManager.getInstance();
for (String origin : API_ORIGINS) {
diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java
index 672a19e4d..917b7be82 100644
--- a/src/plugins/system/android/com/foxdebug/system/System.java
+++ b/src/plugins/system/android/com/foxdebug/system/System.java
@@ -1885,6 +1885,12 @@ private void setDeprecatedSystemUiVisibility(View decorView, int visibility) {
private void getCordovaIntent(CallbackContext callback) {
Intent intent = activity.getIntent();
+ if (isReservedAuthIntent(intent)) {
+ callback.sendPluginResult(
+ new PluginResult(PluginResult.Status.OK, new JSONObject())
+ );
+ return;
+ }
callback.sendPluginResult(
new PluginResult(PluginResult.Status.OK, getIntentJson(intent))
);
@@ -1899,6 +1905,9 @@ private void setIntentHandler(CallbackContext callback) {
@Override
public void onNewIntent(Intent intent) {
+ if (isReservedAuthIntent(intent)) {
+ return;
+ }
if (intentHandler != null) {
PluginResult result = new PluginResult(
PluginResult.Status.OK,
@@ -1909,6 +1918,16 @@ public void onNewIntent(Intent intent) {
}
}
+ private boolean isReservedAuthIntent(Intent intent) {
+ Uri data = intent != null ? intent.getData() : null;
+ if (data == null || !"acode".equals(data.getScheme())) {
+ return false;
+ }
+ String host = data.getHost();
+ String path = data.getPath();
+ return "auth".equals(host) && "/callback".equals(path);
+ }
+
private JSONObject getIntentJson(Intent intent) {
JSONObject json = new JSONObject();
try {
diff --git a/src/test/sanity.tests.js b/src/test/sanity.tests.js
index 61971c8d9..22802918c 100644
--- a/src/test/sanity.tests.js
+++ b/src/test/sanity.tests.js
@@ -1,4 +1,5 @@
import { getLanguageModeRecommendationSearchKeyword } from "../lib/languageModeRecommendations";
+import { isVersionGreater } from "../utils/version";
import { TestRunner } from "./tester";
export async function runSanityTests(writeOutput) {
@@ -82,6 +83,28 @@ export async function runSanityTests(writeOutput) {
);
});
+ runner.test(
+ "Plugin version comparison only accepts newer versions",
+ (test) => {
+ test.assert(
+ isVersionGreater("1.1.2", "1.1.1"),
+ "Patch updates should be newer",
+ );
+ test.assert(
+ isVersionGreater("1.2.0", "1.1.9"),
+ "Minor updates should be newer",
+ );
+ test.assert(
+ !isVersionGreater("1.1.1", "1.1.1"),
+ "Equal versions should not be updates",
+ );
+ test.assert(
+ !isVersionGreater("1.0.0", "1.1.1"),
+ "Lower remote versions should not be updates",
+ );
+ },
+ );
+
// Run all tests
return await runner.run(writeOutput);
}
diff --git a/src/utils/version.js b/src/utils/version.js
new file mode 100644
index 000000000..1a89aca82
--- /dev/null
+++ b/src/utils/version.js
@@ -0,0 +1,35 @@
+export function parseVersion(version) {
+ const parts = String(version || "")
+ .trim()
+ .replace(/^v/i, "")
+ .split(".");
+
+ if (parts.length !== 3) return null;
+
+ const numbers = parts.map((part) => {
+ if (!/^\d+$/.test(part)) return Number.NaN;
+ return Number(part);
+ });
+
+ if (numbers.some((part) => !Number.isSafeInteger(part))) return null;
+
+ return numbers;
+}
+
+export function compareVersions(versionA, versionB) {
+ const a = parseVersion(versionA);
+ const b = parseVersion(versionB);
+
+ if (!a || !b) return 0;
+
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] > b[i]) return 1;
+ if (a[i] < b[i]) return -1;
+ }
+
+ return 0;
+}
+
+export function isVersionGreater(newVersion, currentVersion) {
+ return compareVersions(newVersion, currentVersion) > 0;
+}