Compare commits

...

17 Commits

Author SHA1 Message Date
GitHub Actions
5a4384ea1b chore: bump version to 4.11.112 [skip ci] 2026-07-08 17:41:01 +00:00
cc974414f8 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m12s
Build / create-release (push) Successful in 14s
2026-07-09 03:40:53 +10:00
3245f41073 refactor: remove Android typography fix and associated styles from ClientNonUIFeatures 2026-07-09 03:40:51 +10:00
GitHub Actions
8b51d78db6 chore: bump version to 4.11.111 [skip ci] 2026-07-08 17:23:31 +00:00
e27ec9d7ab feat: add Android typography fix for Capacitor apps and integrate into ClientNonUIFeatures
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 14s
2026-07-09 03:23:22 +10:00
GitHub Actions
5c18e38314 chore: bump version to 4.11.110 [skip ci] 2026-07-08 16:43:01 +00:00
64e1de954a fix: update subproject commit reference in cinny to b425827
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m12s
Build / create-release (push) Successful in 14s
2026-07-09 02:42:52 +10:00
GitHub Actions
0404cdc8ee chore: bump version to 4.11.109 [skip ci] 2026-07-08 16:07:17 +00:00
9dc177f527 fix: update version code and name in build.gradle; register PluginStoragePlugin in MainActivity; enhance text duplication handling in Editor component
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 15s
2026-07-09 02:07:03 +10:00
655603bb59 fix: update subproject commit reference in cinny to f67f08f1 2026-07-09 02:06:56 +10:00
GitHub Actions
fb9e8bc277 chore: bump version to 4.11.108 [skip ci] 2026-05-13 01:12:28 +00:00
803a076f98 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m34s
Build / create-release (push) Successful in 14s
2026-05-13 11:12:22 +10:00
86d284b353 fix: update subproject commit reference in cinny 2026-05-13 11:12:17 +10:00
6207d18b3c fix: update version code and name in build.gradle; adjust Modal component in ShareRoomPicker 2026-05-13 11:12:09 +10:00
GitHub Actions
8d2e35d1bc chore: bump version to 4.11.107 [skip ci] 2026-05-13 00:40:41 +00:00
6845398755 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m32s
Build / create-release (push) Successful in 14s
2026-05-13 10:40:34 +10:00
be52b0fa08 fix(mobile): update version code and name, adjust padding in ShareRoomPicker component 2026-05-13 10:40:31 +10:00
14 changed files with 1377 additions and 37 deletions

View File

@@ -27,8 +27,8 @@ android {
applicationId "com.paarrot.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1777176952
versionName "2026-04-26.041552.151"
versionCode 1783526741
versionName "2026-07-08.160541.766"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -8,6 +8,7 @@ public class MainActivity extends BridgeActivity {
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(SyncServicePlugin.class);
registerPlugin(ShareHandlerPlugin.class);
registerPlugin(PluginStoragePlugin.class);
super.onCreate(savedInstanceState);
}
}

View File

@@ -0,0 +1,250 @@
package com.paarrot.app
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipInputStream
/** Capacitor plugin that stores and manages user extension plugins on Android. */
@CapacitorPlugin(name = "PluginStorage")
class PluginStoragePlugin : Plugin() {
private fun pluginsDir(): File {
val dir = File(context.filesDir, "plugins")
dir.mkdirs()
return dir
}
@PluginMethod
fun getPath(call: PluginCall) {
val result = JSObject()
result.put("success", true)
result.put("data", pluginsDir().absolutePath)
call.resolve(result)
}
@PluginMethod
fun list(call: PluginCall) {
try {
val plugins = pluginsDir().listFiles()?.filter { it.isDirectory } ?: emptyList()
val array = JSArray()
for (dir in plugins) {
val metadata = readMetadata(dir)
val obj = JSObject()
obj.put("id", dir.name)
obj.put("name", metadata?.optString("name", dir.name) ?: dir.name)
obj.put("version", metadata?.optString("version", "") ?: "")
obj.put("description", metadata?.optString("description", "") ?: "")
obj.put("author", metadata?.optString("author", "") ?: "")
obj.put("repository", metadata?.optString("repository", "") ?: "")
obj.put("thumbnail", metadata?.optString("thumbnail", "") ?: "")
obj.put("homepage", metadata?.optString("homepage", "") ?: "")
obj.put("tags", metadataTagsToJsArray(metadata))
obj.put("installedDate", formatInstalledDate(dir.lastModified()))
obj.put("path", dir.absolutePath)
array.put(obj)
}
val result = JSObject()
result.put("success", true)
result.put("data", array)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to list plugins")
call.resolve(result)
}
}
@PluginMethod
fun download(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
val downloadUrl = call.getString("downloadUrl")
?: return call.reject("downloadUrl required")
val name = call.getString("name") ?: pluginId
Thread {
try {
val destDir = File(pluginsDir(), pluginId)
if (destDir.exists()) {
destDir.deleteRecursively()
}
destDir.mkdirs()
val tempZip = File(context.cacheDir, "plugin-download-$pluginId.zip")
downloadFile(downloadUrl, tempZip)
extractZip(tempZip, destDir)
tempZip.delete()
normalisePluginLayout(destDir)
if (!File(destDir, "index.js").exists()) {
val result = JSObject()
result.put("success", false)
result.put("error", "Downloaded plugin is missing index.js")
call.resolve(result)
return@Thread
}
val result = JSObject()
result.put("success", true)
val data = JSObject()
data.put("path", destDir.absolutePath)
result.put("data", data)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to download plugin: $name")
call.resolve(result)
}
}.start()
}
@PluginMethod
fun uninstall(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
try {
val destDir = File(pluginsDir(), pluginId)
if (destDir.exists()) {
destDir.deleteRecursively()
}
val result = JSObject()
result.put("success", true)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to uninstall plugin")
call.resolve(result)
}
}
@PluginMethod
fun readPluginCode(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
val indexFile = File(pluginsDir(), "$pluginId/index.js")
if (!indexFile.exists()) {
val result = JSObject()
result.put("success", false)
result.put("error", "Plugin not found: $pluginId")
call.resolve(result)
return
}
val result = JSObject()
result.put("success", true)
result.put("data", indexFile.readText())
call.resolve(result)
}
private fun readMetadata(pluginDir: File): JSONObject? {
val metadataFile = File(pluginDir, "plugin-metadata.json")
if (!metadataFile.exists()) return null
return try {
JSONObject(metadataFile.readText())
} catch (_: Exception) {
null
}
}
private fun metadataTagsToJsArray(metadata: JSONObject?): JSArray {
val tags = JSArray()
val jsonTags = metadata?.optJSONArray("tags") ?: return tags
for (i in 0 until jsonTags.length()) {
tags.put(jsonTags.optString(i))
}
return tags
}
private fun formatInstalledDate(timestamp: Long): String {
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
return formatter.format(Date(timestamp))
}
private fun downloadFile(downloadUrl: String, destFile: File) {
val connection = (URL(downloadUrl).openConnection() as HttpURLConnection).apply {
connectTimeout = 30_000
readTimeout = 60_000
instanceFollowRedirects = true
requestMethod = "GET"
}
try {
connection.connect()
if (connection.responseCode !in 200..299) {
throw IllegalStateException("HTTP ${connection.responseCode}")
}
connection.inputStream.use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
} finally {
connection.disconnect()
}
}
private fun extractZip(zipFile: File, destDir: File) {
destDir.mkdirs()
val destPath = destDir.canonicalPath + File.separator
ZipInputStream(zipFile.inputStream()).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
val outFile = File(destDir, entry.name)
if (!outFile.canonicalPath.startsWith(destPath)) {
throw SecurityException("Zip entry outside target directory")
}
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { output ->
zipInput.copyTo(output)
}
}
zipInput.closeEntry()
entry = zipInput.nextEntry
}
}
}
/** Flatten single-root zip layouts so index.js ends up directly under the plugin dir. */
private fun normalisePluginLayout(destDir: File) {
if (File(destDir, "index.js").exists()) return
val children = destDir.listFiles()?.filter { it.isDirectory } ?: return
if (children.size != 1) return
val nested = children[0]
nested.listFiles()?.forEach { child ->
child.renameTo(File(destDir, child.name))
}
nested.deleteRecursively()
}
}

2
cinny

Submodule cinny updated: 7ef9939d8a...b4258278d8

View File

@@ -18,6 +18,7 @@ import {
RenderLeafProps,
RenderElementProps,
RenderPlaceholderProps,
ReactEditor,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { BlockType } from './types';
@@ -172,27 +173,44 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle autocorrect replacement that causes text duplication
if (inputEvent.inputType === 'insertReplacementText' ||
inputEvent.inputType === 'insertFromComposition') {
// Handle mobile autocorrect replacement that causes text duplication in Slate
if (inputEvent.inputType === 'insertReplacementText') {
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (!data) return;
event.preventDefault();
const domRanges = inputEvent.getTargetRanges?.() ?? [];
if (domRanges.length > 0) {
const slateRange = ReactEditor.toSlateRange(editor, domRanges[0], {
exactMatch: false,
suppressThrow: true,
});
if (slateRange) {
Transforms.select(editor, slateRange);
Transforms.delete(editor);
editor.insertText(data);
return;
}
}
const { selection } = editor;
if (!selection) return;
// Get the data being inserted
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (data) {
event.preventDefault();
// If there's selected text, delete it first
if (selection && !Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
if (!Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
} else {
const wordBefore = Editor.before(editor, selection.anchor, { unit: 'word' });
if (wordBefore) {
const wordRange = { anchor: wordBefore, focus: selection.anchor };
Transforms.select(editor, wordRange);
Transforms.delete(editor);
}
// Insert the replacement text
editor.insertText(data);
}
editor.insertText(data);
}
},
[editor]

View File

@@ -127,7 +127,8 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
const fileCount = share.files.length;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<>
<Overlay open>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
@@ -144,16 +145,17 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
inset: 0,
zIndex: 999,
backgroundColor: '#1a1d23',
paddingTop: '60px',
}}
>
<Header
size="500"
style={{ padding: config.space.S400, paddingTop: '60px' }}
style={{ padding: config.space.S400 }}
>
<Box grow="Yes" direction="Column" gap="100">
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
@@ -170,6 +172,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
<Box
style={{
padding: config.space.S200,
paddingTop: config.space.S300,
}}
>
<Input
@@ -185,7 +188,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
</Box>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Box style={{ padding: config.space.S300, paddingTop: config.space.S300 }} direction="Column">
<Box style={{ padding: config.space.S300 }} direction="Column">
{vItems.length === 0 && (
<Box
style={{ padding: `${config.space.S700} 0` }}
@@ -261,13 +264,15 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
</Scroll>
</Box>
</FocusTrap>
</Overlay>
{confirmRoomId && (() => {
const room = getRoom(confirmRoomId);
const roomName = room?.name ?? confirmRoomId;
const isDm = mDirects.has(confirmRoomId);
return (
<OverlayCenter style={{ zIndex: 9999 }}>
{confirmRoomId && (() => {
const room = getRoom(confirmRoomId);
const roomName = room?.name ?? confirmRoomId;
const isDm = mDirects.has(confirmRoomId);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
@@ -275,9 +280,9 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" style={{ zIndex: 9999 }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="200">
<Modal style={{ maxWidth: '280px' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
<Text size="T300" priority="300">
Send to <strong>{roomName}</strong>?
@@ -303,8 +308,9 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
</Modal>
</FocusTrap>
</OverlayCenter>
);
})()}
</Overlay>
</Overlay>
);
})()}
</>
);
}

View File

@@ -0,0 +1,149 @@
/**
* Re-exports the full @paarrot/plugin-manager API and creates the shared
* singleton PluginRegistry instance for this application.
*
* All other modules in this folder should import from here rather than
* directly from the package so the singleton is guaranteed to be shared.
*/
export {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
createPluginContext,
generateThemeCSS,
MemoryStorage,
} from '@paarrot/plugin-manager';
export type {
IPluginStorage,
IPluginEventClient,
PluginMarketplaceClientOptions,
PluginMarketplaceHostAdapter,
PluginMarketplaceInstalledRecord,
PluginMarketplaceManagerOptions,
PluginRegistryOptions,
PluginContextOptions,
PluginMetadata,
InstalledPlugin,
PluginIndex,
ThemeColorGroup,
ThemePaletteGroup,
PluginThemeColors,
PluginTheme,
CommandArg,
PluginCommand,
MessageInterceptor,
MessageContext,
CustomRenderer,
SettingDefinition,
SettingsSchema,
NotificationOptions,
PluginContext,
PluginSettingsSection,
UILocation,
UIButtonDefinition,
UIButtonPosition,
Plugin,
PluginLogEntry,
} from '@paarrot/plugin-manager';
export { PluginTab } from '@paarrot/plugin-manager';
import {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
} from '@paarrot/plugin-manager';
import { getPluginHost } from '../../../utils/pluginHost';
const PLUGIN_INDEX_URL =
import.meta.env.VITE_PLUGIN_INDEX_URL ||
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`;
const PLUGIN_BASE_URL =
import.meta.env.VITE_PLUGIN_BASE_URL ||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
function injectThemeStyle(styleId: string, css: string): void {
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = css;
}
function removeThemeStyle(styleId: string): void {
document.getElementById(styleId)?.remove();
}
/** Shared singleton registry for the entire application. */
export const pluginRegistry = new PluginRegistry({
storage: localStorage,
onThemeRegistered: (themeId, _className, css) =>
injectThemeStyle(`plugin-theme-${themeId}`, css),
onThemeUnregistered: (themeId) => removeThemeStyle(`plugin-theme-${themeId}`),
});
/** Shared marketplace client for remote plugin directory reads. */
export const pluginMarketplaceClient = new PluginMarketplaceClient({
indexUrl: PLUGIN_INDEX_URL,
baseUrl: PLUGIN_BASE_URL,
});
/**
* Shared marketplace manager for install state, directory fetch, and enable flags.
*/
export const pluginMarketplaceManager = new PluginMarketplaceManager({
client: pluginMarketplaceClient,
storage: localStorage,
registry: pluginRegistry,
host: {
listInstalledPlugins: async () => {
const host = getPluginHost();
if (!host) {
return [];
}
const result = await host.list();
if (!result.success || !result.data) {
throw new Error(result.error ?? 'Failed to list installed plugins');
}
return result.data.map((plugin) => ({
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
author: plugin.author,
repository: plugin.repository,
thumbnail: plugin.thumbnail,
homepage: plugin.homepage,
tags: plugin.tags,
installedDate: plugin.installedDate,
}));
},
installPlugin: async (plugin) => {
const host = getPluginHost();
if (!host) {
return;
}
const result = await host.download(plugin.id, plugin.downloadUrl, plugin.name);
if (!result.success) {
throw new Error(result.error ?? 'Failed to install plugin');
}
},
uninstallPlugin: async (pluginId) => {
const host = getPluginHost();
if (!host) {
return;
}
const result = await host.uninstall(pluginId);
if (!result.success) {
throw new Error(result.error ?? 'Failed to uninstall plugin');
}
},
},
});

View File

@@ -0,0 +1,190 @@
import React, { useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
import { sendNotification } from '../../../utils/tauri';
import { getPluginHost } from '../../../utils/pluginHost';
import { dispatchPluginButtonsChanged } from './PluginButtonSlot';
interface PluginLoaderProps {
matrixClient: MatrixClient;
children: React.ReactNode;
}
/**
* Component that loads and initializes enabled plugins.
* Should be placed high in the component tree after MatrixClient is available.
*/
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
useEffect(() => {
const loadPlugins = async () => {
try {
const pluginHost = getPluginHost();
console.log('[PluginLoader] Loading plugins...');
console.log('[PluginLoader] plugin host available:', !!pluginHost);
if (!pluginHost) {
console.log('[PluginLoader] Running in web mode, plugins not supported');
return;
}
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map((p) => p.id));
for (const installedPlugin of pluginsToLoad) {
try {
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
const pluginModule = await loadPluginModule(installedPlugin.id);
const plugin = normalisePlugin(pluginModule);
if (!plugin || typeof plugin !== 'object') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object`
);
continue;
}
if (typeof plugin.onLoad !== 'function') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
);
continue;
}
const context = createPluginContext(
{
pluginId: installedPlugin.id,
eventClient: matrixClient as any,
onNotify: async (opts) => {
await sendNotification({
title: opts.title,
body: opts.body,
path: undefined,
});
},
},
pluginRegistry
);
const compatContext = Object.assign(context as Record<string, unknown>, {
matrixClient,
matrix: {
on: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.on(eventType as any, handler as any),
off: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.off(eventType as any, handler as any),
},
React,
});
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
console.log('[PluginLoader] About to register plugin and call onLoad...');
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
await plugin.onLoad(compatContext as any);
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
dispatchPluginButtonsChanged();
} catch (error) {
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
}
}
} catch (error) {
console.error('[PluginLoader] Error loading plugins:', error);
}
};
loadPlugins();
return () => {
console.log('[PluginLoader] Cleaning up plugins');
pluginRegistry.clear();
dispatchPluginButtonsChanged();
};
}, [matrixClient]);
return <>{children}</>;
}
/**
* Reads and evaluates a plugin's JS module from the native filesystem host.
* Supports both CommonJS and ESM-style exports.
*/
async function loadPluginModule(pluginId: string): Promise<any> {
const pluginHost = getPluginHost();
if (!pluginHost) {
throw new Error('Plugins are only supported in the desktop or Android app');
}
const response = await pluginHost.readPluginCode(pluginId);
if (!response.success || !response.data) {
throw new Error(response.error ?? 'Failed to read plugin code');
}
const pluginExports: any = {};
const mod = { exports: pluginExports };
try {
// eslint-disable-next-line no-new-func
const pluginFunction = new Function('module', 'exports', response.data);
pluginFunction(mod, pluginExports);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
const importedModule = await import(/* @vite-ignore */ dataUrl);
return importedModule;
}
return mod.exports;
}
/**
* Normalizes raw plugin exports to current plugin interface.
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
*/
function normalisePlugin(raw: Record<string, unknown>): Plugin {
if (typeof raw.onLoad === 'function') {
return raw as unknown as Plugin;
}
if (typeof raw.activate === 'function') {
const activate = raw.activate as (ctx: unknown) => void | Promise<void>;
const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise<void>) | undefined;
return {
name: typeof raw.name === 'string' ? raw.name : undefined,
version: typeof raw.version === 'string' ? raw.version : undefined,
onLoad: async (ctx) => {
const legacyCtx = Object.assign(Object.create(ctx as object), {
registerHook: (name: string) =>
ctx.log(`[compat] registerHook("${name}") - not supported`),
runHook: (name: string) => {
ctx.log(`[compat] runHook("${name}")`);
return Promise.resolve([]);
},
getConfig: (key: string) => ctx.settings.get(key),
on: (event: string, handler: (...args: unknown[]) => void) =>
ctx.events.on(event, handler),
});
await activate(legacyCtx);
},
onUnload: deactivate
? async () => {
await deactivate();
}
: undefined,
};
}
return raw as unknown as Plugin;
}

View File

@@ -0,0 +1,72 @@
import { Capacitor, registerPlugin } from '@capacitor/core';
export type PluginHostListItem = {
id: string;
name: string;
version?: string;
description?: string;
author?: string;
repository?: string;
thumbnail?: string;
homepage?: string;
tags?: string[];
installedDate: string;
path: string;
};
export type PluginHostResult<T> = {
success: boolean;
data?: T;
error?: string;
};
export interface PluginHostAPI {
getPath: () => Promise<PluginHostResult<string>>;
download: (
pluginId: string,
downloadUrl: string,
name: string
) => Promise<PluginHostResult<{ path: string }>>;
list: () => Promise<PluginHostResult<PluginHostListItem[]>>;
uninstall: (pluginId: string) => Promise<PluginHostResult<void>>;
readPluginCode: (pluginId: string) => Promise<PluginHostResult<string>>;
}
interface PluginStoragePlugin {
getPath(): Promise<PluginHostResult<string>>;
download(options: {
pluginId: string;
downloadUrl: string;
name: string;
}): Promise<PluginHostResult<{ path: string }>>;
list(): Promise<PluginHostResult<PluginHostListItem[]>>;
uninstall(options: { pluginId: string }): Promise<PluginHostResult<void>>;
readPluginCode(options: { pluginId: string }): Promise<PluginHostResult<string>>;
}
const PluginStorage = registerPlugin<PluginStoragePlugin>('PluginStorage');
const androidPluginHost: PluginHostAPI = {
getPath: () => PluginStorage.getPath(),
download: (pluginId, downloadUrl, name) =>
PluginStorage.download({ pluginId, downloadUrl, name }),
list: () => PluginStorage.list(),
uninstall: (pluginId) => PluginStorage.uninstall({ pluginId }),
readPluginCode: (pluginId) => PluginStorage.readPluginCode({ pluginId }),
};
/** Returns true when a native plugin filesystem host is available. */
export const isPluginHostSupported = (): boolean => getPluginHost() !== null;
/** Returns the active plugin host for Electron or Android. */
export const getPluginHost = (): PluginHostAPI | null => {
if (window.electron?.plugins) {
return window.electron.plugins;
}
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
return androidPluginHost;
}
return null;
};

View File

@@ -248,6 +248,24 @@ export const isAndroid = (): boolean => {
return ua.includes('android');
};
/** Returns true when running as a Capacitor app on Android. */
export const isCapacitorAndroid = (): boolean => {
if (typeof window === 'undefined') return false;
const cap = (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor;
return Boolean(cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android');
};
/**
* Android WebView: keep emoji fonts out of the body text stack and use system UI fonts.
*/
export const applyAndroidTypographyFix = (): void => {
if (!isCapacitorAndroid()) return;
const root = document.documentElement;
root.classList.add('android-capacitor');
root.style.setProperty('--font-secondary', 'system-ui, Roboto, "Noto Sans", sans-serif');
};
/**
* Apply safe area insets for mobile devices
* On Android, CSS env() may not work, so we apply fallback padding

18
overlay/src/font-setup.ts Normal file
View File

@@ -0,0 +1,18 @@
const cap =
typeof window !== 'undefined'
? (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor
: undefined;
export const isCapacitorAndroid = Boolean(
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
);
if (isCapacitorAndroid) {
document.documentElement.classList.add('android-capacitor');
document.documentElement.style.setProperty(
'--font-secondary',
'system-ui, Roboto, "Noto Sans", sans-serif'
);
} else {
await import('@fontsource-variable/inter');
}

506
overlay/src/index.css Normal file
View File

@@ -0,0 +1,506 @@
/* Twemoji font (Twitter emoji) */
@font-face {
font-family: Twemoji;
src: url('../public/font/Twemoji.Mozilla.v15.1.0.woff2'),
url('../public/font/Twemoji.Mozilla.v15.1.0.ttf');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
/* Apple Color Emoji - uses local Apple Color Emoji if available (macOS/iOS),
falls back to Segoe UI Emoji (Windows) or Noto (Linux/Android) */
@font-face {
font-family: AppleColorEmoji;
src: local('Apple Color Emoji'),
local('Segoe UI Emoji'),
local('Noto Color Emoji');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
/* System emoji - uses native OS emoji */
@font-face {
font-family: SystemEmoji;
src: local('Apple Color Emoji'),
local('Segoe UI Emoji'),
local('Segoe UI Symbol'),
local('Noto Color Emoji'),
local('Android Emoji'),
local('EmojiOne Color');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
:root {
--tc-link: hsl(213deg 100% 45%);
/* user mxid colors */
--mx-uc-1: hsl(208, 100%, 45%);
--mx-uc-2: hsl(302, 100%, 30%);
--mx-uc-3: hsl(163, 100%, 30%);
--mx-uc-4: hsl(343, 100%, 45%);
--mx-uc-5: hsl(24, 100%, 45%);
--mx-uc-6: hsl(181, 100%, 30%);
--mx-uc-7: hsl(242, 100%, 45%);
--mx-uc-8: hsl(94, 100%, 35%);
--font-emoji: 'AppleColorEmoji';
--font-secondary: 'InterVariable', sans-serif;
}
.dark-theme,
.butter-theme,
.discord-theme,
.discord-darker-theme,
.twilight-theme,
.mocha-theme {
--tc-link: hsl(213deg 100% 80%);
--mx-uc-1: hsl(208, 100%, 75%);
--mx-uc-2: hsl(301, 100%, 80%);
--mx-uc-3: hsl(163, 100%, 70%);
--mx-uc-4: hsl(343, 100%, 75%);
--mx-uc-5: hsl(24, 100%, 70%);
--mx-uc-6: hsl(181, 100%, 60%);
--mx-uc-7: hsl(243, 100%, 80%);
--mx-uc-8: hsl(94, 100%, 80%);
--font-secondary: 'InterVariable', sans-serif;
}
html {
height: 100%;
overflow: hidden;
/* Enable safe area variables */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
}
body {
margin: 0;
padding: 0;
/* Safe area insets for mobile devices (notch, navigation bar) */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
height: 100%;
font-family: var(--font-secondary);
font-size: 16px;
font-weight: 400;
/* Default to dark theme background for safe areas */
background-color: #262626;
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
font-variant-ligatures: no-contextual;
}
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
}
/* Theme-specific body backgrounds for safe area padding */
body.light-theme {
background-color: #F0F0F0;
}
body.silver-theme {
background-color: #DEDEDE;
}
body.dark-theme {
background-color: #262626;
}
body.butter-theme {
background-color: #1A1916;
}
body.discord-theme {
background-color: #2B2D31;
}
body.discord-darker-theme {
background-color: #1E1F22;
}
body.twilight-theme {
background: linear-gradient(135deg, #14121F 0%, #1C1A2E 50%, #24223D 100%);
}
body.mocha-theme {
background: linear-gradient(135deg, #1A1614 0%, #242019 50%, #2D2721 100%);
}
#root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #262626;
}
.twilight-theme #root {
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
}
.mocha-theme #root {
background: linear-gradient(180deg, rgba(36, 32, 25, 0.5) 0%, rgba(45, 39, 33, 0.3) 100%);
}
/* Twilight theme gradient overlays */
.twilight-theme [role="navigation"],
.twilight-theme aside {
background: linear-gradient(180deg, rgba(20, 18, 31, 0.8) 0%, rgba(28, 26, 46, 0.9) 100%);
backdrop-filter: blur(8px);
}
.twilight-theme main {
background: linear-gradient(135deg, rgba(24, 22, 45, 0.4) 0%, rgba(36, 34, 61, 0.3) 100%);
}
.twilight-theme header {
background: linear-gradient(90deg, rgba(20, 18, 31, 0.9) 0%, rgba(28, 26, 46, 0.8) 100%);
}
/* CSS animations for route transitions */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Apply transition to route changes - works on ALL themes */
[data-route-transition="true"] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Twilight theme enhanced transitions */
.twilight-theme [data-route-transition="true"] {
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Also apply to main content areas for double coverage */
.twilight-theme [data-room-view],
.twilight-theme [data-space-view],
.twilight-theme [data-inbox-view],
.twilight-theme [data-explore-view] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Smooth transitions for interactive elements */
.twilight-theme button,
.twilight-theme [role="button"],
.twilight-theme a,
.twilight-theme input,
.twilight-theme textarea {
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.twilight-theme button:hover,
.twilight-theme [role="button"]:hover {
transform: translateY(-1px);
}
.twilight-theme button:active,
.twilight-theme [role="button"]:active {
transform: translateY(0);
}
/* Message transitions */
.twilight-theme [data-message-item] {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.twilight-theme [data-message-item]:hover {
background: linear-gradient(90deg, rgba(62, 58, 106, 0.15) 0%, rgba(86, 80, 150, 0.1) 100%);
transform: translateX(2px);
}
/* Selected item indicator */
.twilight-theme [data-selected="true"],
.twilight-theme [aria-selected="true"],
.twilight-theme [aria-current="true"] {
position: relative;
transition: all 0.2s ease;
}
.twilight-theme [data-selected="true"]::before,
.twilight-theme [aria-selected="true"]::before,
.twilight-theme [aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #8B7FFF 0%, #6E62E8 100%);
transition: width 0.2s ease;
}
/* Scrollbar styling */
.twilight-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.twilight-theme ::-webkit-scrollbar-track {
background: rgba(20, 18, 31, 0.5);
}
.twilight-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
border-radius: 5px;
border: 2px solid rgba(20, 18, 31, 0.5);
transition: background 0.2s ease;
}
.twilight-theme ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
}
/* Mocha theme gradient overlays */
.mocha-theme [role="navigation"],
.mocha-theme aside {
background: linear-gradient(180deg, rgba(26, 22, 20, 0.8) 0%, rgba(36, 32, 25, 0.9) 100%);
backdrop-filter: blur(8px);
}
.mocha-theme main {
background: linear-gradient(135deg, rgba(36, 32, 25, 0.4) 0%, rgba(45, 39, 33, 0.3) 100%);
}
.mocha-theme header {
background: linear-gradient(90deg, rgba(26, 22, 20, 0.9) 0%, rgba(36, 32, 25, 0.8) 100%);
}
/* Mocha theme enhanced transitions */
.mocha-theme [data-route-transition="true"] {
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.mocha-theme [data-room-view],
.mocha-theme [data-space-view],
.mocha-theme [data-inbox-view],
.mocha-theme [data-explore-view] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Smooth transitions for interactive elements */
.mocha-theme button,
.mocha-theme [role="button"],
.mocha-theme a,
.mocha-theme input,
.mocha-theme textarea {
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.mocha-theme button:hover,
.mocha-theme [role="button"]:hover {
transform: translateY(-1px);
}
.mocha-theme button:active,
.mocha-theme [role="button"]:active {
transform: translateY(0);
}
/* Message transitions */
.mocha-theme [data-message-item] {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.mocha-theme [data-message-item]:hover {
background: linear-gradient(90deg, rgba(84, 73, 63, 0.15) 0%, rgba(112, 97, 81, 0.1) 100%);
transform: translateX(2px);
}
/* Selected item indicator */
.mocha-theme [data-selected="true"],
.mocha-theme [aria-selected="true"],
.mocha-theme [aria-current="true"] {
position: relative;
transition: all 0.2s ease;
}
.mocha-theme [data-selected="true"]::before,
.mocha-theme [aria-selected="true"]::before,
.mocha-theme [aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #D4A574 0%, #B48660 100%);
transition: width 0.2s ease;
}
/* Scrollbar styling */
.mocha-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.mocha-theme ::-webkit-scrollbar-track {
background: rgba(26, 22, 20, 0.5);
}
.mocha-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
border-radius: 5px;
border: 2px solid rgba(26, 22, 20, 0.5);
transition: background 0.2s ease;
}
.mocha-theme ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #625548 0%, #7E6D5A 100%);
}
/* Subtle dot pattern overlay */
.mocha-theme::after {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(212, 165, 116, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
.mocha-theme #root {
position: relative;
z-index: 1;
}
/* Focus states */
.mocha-theme input:focus,
.mocha-theme textarea:focus,
.mocha-theme button:focus-visible {
outline: 2px solid rgba(212, 165, 116, 0.5);
outline-offset: 2px;
transition: outline 0.2s ease;
}
/* Subtle dot pattern overlay */
.twilight-theme::after {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(139, 127, 255, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
.twilight-theme #root {
position: relative;
z-index: 1;
}
/* Focus states */
.twilight-theme input:focus,
.twilight-theme textarea:focus,
.twilight-theme button:focus-visible {
outline: 2px solid rgba(139, 127, 255, 0.5);
outline-offset: 2px;
transition: outline 0.2s ease;
}
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent;
}
a {
color: var(--tc-link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
[data-mx-spoiler][aria-pressed='true'] a {
color: transparent;
pointer-events: none;
}
b {
font-weight: 500;
}
label {
margin: 0;
padding: 0;
}
button,
textarea {
margin: 0;
padding: 0;
background-color: transparent;
color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
letter-spacing: inherit;
border: none;
}
button {
max-width: 100%;
text-transform: none;
text-align: inherit;
overflow: visible;
-webkit-appearance: button;
}
textarea,
input,
input[type],
input[type='text'],
input[type='username'],
input[type='password'],
input[type='email'],
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
textarea {
color: inherit;
word-spacing: inherit;
}
audio:not([controls]) {
display: none !important;
}
/* Android WebView: Inter Variable + emoji fonts in the text stack break space metrics */
html.android-capacitor,
html.android-capacitor.dark-theme,
html.android-capacitor.butter-theme,
html.android-capacitor.discord-theme,
html.android-capacitor.discord-darker-theme,
html.android-capacitor.twilight-theme,
html.android-capacitor.mocha-theme,
html.android-capacitor.light-theme,
html.android-capacitor.silver-theme {
--font-secondary: system-ui, Roboto, 'Noto Sans', sans-serif;
}
html.android-capacitor body {
font-family: system-ui, Roboto, 'Noto Sans', sans-serif;
letter-spacing: normal;
word-spacing: normal;
font-variant-ligatures: normal;
}

112
overlay/src/index.tsx Normal file
View File

@@ -0,0 +1,112 @@
/* eslint-disable import/first */
import React from 'react';
import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
await import('./font-setup');
import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds';
enableMapSet();
import './index.css';
import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
// import i18n (needs to be bundled ;))
import './app/i18n';
document.body.classList.add(configClass, varsClass);
// Apply safe area insets for mobile devices
applySafeAreaInsets();
// Enable View Transitions API for smooth navigation
enableViewTransitionsForNavigation();
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =
import.meta.env.MODE === 'production'
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
navigator.serviceWorker.register(swUrl);
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'token' && event.data?.responseKey) {
const getCurrentAccessToken = async () => {
const { getCurrentAccessToken: getToken } = await import('./app/utils/auth');
return getToken();
};
getCurrentAccessToken().then((token) => {
event.source!.postMessage({
responseKey: event.data.responseKey,
token,
});
});
}
});
}
const isElectron = (): boolean => 'electron' in window;
async function checkForUpdates() {
if (isElectron()) {
console.log('Update check skipped - Electron handles updates natively');
return;
}
if (!isTauri() || isElectron()) {
console.log('Update check skipped - not running in Tauri');
return;
}
console.log('Checking for updates...');
try {
const { check } = await import('@tauri-apps/plugin-updater');
const { ask } = await import('@tauri-apps/plugin-dialog');
const { relaunch } = await import('@tauri-apps/plugin-process');
const update = await check();
console.log('Update check result:', update);
if (update) {
console.log(`Update available: ${update.version}`);
const shouldUpdate = await ask(
`A new version (${update.version}) is available. Would you like to update now?`,
{ title: 'Update Available', kind: 'info' }
);
if (shouldUpdate) {
console.log('User chose to update, downloading...');
await update.downloadAndInstall();
await relaunch();
}
} else {
console.log('App is up to date');
}
} catch (error) {
console.error('Failed to check for updates:', error);
}
}
const mountApp = () => {
const rootContainer = document.getElementById('root');
if (rootContainer === null) {
console.error('Root container element not found!');
return;
}
const root = createRoot(rootContainer);
root.render(<App />);
};
mountApp();
setTimeout(() => {
checkForUpdates();
}, 3000);

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.106",
"version": "4.11.112",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"