Improve assets loading

This commit is contained in:
Hakan Shehu
2025-09-18 14:08:01 +02:00
parent aa0e31c891
commit 56c5aa8a04
17 changed files with 252 additions and 141 deletions

5
.gitignore vendored
View File

@@ -155,8 +155,6 @@ src/scripts/icons/temp/
# Ignore desktop assets
apps/desktop/assets/emojis.db
apps/desktop/assets/icons.db
apps/desktop/assets/emojis.svg
apps/desktop/assets/icons.svg
apps/desktop/assets/fonts/neotrax.otf
apps/desktop/assets/colanode-logo-black.png
apps/desktop/assets/colanode-logo-black.ico
@@ -173,6 +171,9 @@ apps/web/public/assets/colanode-logo-black-512.png
# Ignore mobile assets
apps/mobile/assets/ui/index.html
apps/mobile/assets/emojis.db
apps/mobile/assets/icons.db
apps/mobile/assets/fonts/neotrax.otf
.expo
web-build/

View File

@@ -7,6 +7,7 @@ import {
globalShortcut,
dialog,
} from 'electron';
import path from 'path';
import started from 'electron-squirrel-startup';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
@@ -56,9 +57,9 @@ const createWindow = async () => {
fullscreenable: true,
minWidth: 800,
minHeight: 600,
icon: app.path.join(app.path.assets, 'colanode-logo-black.png'),
icon: path.join(app.path.assets, 'colanode-logo-black.png'),
webPreferences: {
preload: app.path.join(__dirname, 'preload.js'),
preload: path.join(__dirname, 'preload.js'),
},
autoHideMenuBar: true,
titleBarStyle: 'hiddenInset',
@@ -104,10 +105,7 @@ const createWindow = async () => {
// mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(
app.path.join(
__dirname,
`../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`
)
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)
);
}

View File

@@ -154,4 +154,8 @@ export class DesktopPathService implements PathService {
public get iconsDatabase(): string {
return this.nativePath.join(this.getAssetsSourcePath(), 'icons.db');
}
public font(name: string): string {
return this.nativePath.join(this.getAssetsSourcePath(), 'fonts', name);
}
}

View File

@@ -26,9 +26,6 @@
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-asset",
"expo-sqlite"
]
"plugins": ["expo-asset", "expo-sqlite"]
}
}

View File

@@ -1,25 +1,31 @@
import { Asset } from 'expo-asset';
import { modelName } from 'expo-device';
import { useCallback, useEffect, useRef, useState } from 'react';
import { View, ActivityIndicator, Platform } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { eventBus } from '@colanode/client/lib';
import { AppMeta, AppService } from '@colanode/client/services';
import { generateId, IdType } from '@colanode/core';
import { copyAssets, indexHtmlAsset } from '@colanode/mobile/lib/assets';
import { Message } from '@colanode/mobile/lib/types';
import { app } from '@colanode/mobile/services/app-service';
import indexHtml from '../assets/ui/index.html';
import { MobileFileSystem } from '@colanode/mobile/services/file-system';
import { MobileKyselyService } from '@colanode/mobile/services/kysely-service';
import { MobilePathService } from '@colanode/mobile/services/path-service';
export const App = () => {
const windowId = useRef<string>(generateId(IdType.Window));
const webViewRef = useRef<WebView>(null);
const app = useRef<AppService | null>(null);
const appInitialized = useRef<boolean>(false);
const [uri, setUri] = useState<string | null>(null);
const [baseDir, setBaseDir] = useState<string | null>(null);
const webViewRef = useRef<WebView>(null);
useEffect(() => {
(async () => {
const indexAsset = Asset.fromModule(indexHtml);
await indexAsset.downloadAsync(); // no-op in prod
const indexAsset = Asset.fromModule(indexHtmlAsset);
await indexAsset.downloadAsync();
const localUri = indexAsset.localUri ?? indexAsset.uri;
const dir = localUri.replace(/index\.html$/, '');
setUri(localUri);
@@ -27,28 +33,91 @@ export const App = () => {
})();
}, []);
useEffect(() => {
(async () => {
try {
const paths = new MobilePathService();
await copyAssets(paths);
const appMeta: AppMeta = {
type: 'mobile',
platform: modelName ?? 'unknown',
};
app.current = new AppService(
appMeta,
new MobileFileSystem(),
new MobileKyselyService(),
paths
);
await app.current.migrate();
await app.current.init();
appInitialized.current = true;
} catch (error) {
console.error(error);
}
})();
}, []);
const handleMessage = useCallback(async (e: WebViewMessageEvent) => {
const message = JSON.parse(e.nativeEvent.data) as Message;
if (message.type === 'console') {
console.log(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
if (message.level === 'log') {
console.log(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
} else if (message.level === 'warn') {
console.warn(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
} else if (message.level === 'error') {
console.error(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
} else if (message.level === 'info') {
console.info(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
} else if (message.level === 'debug') {
console.debug(
`[WebView ${message.level.toUpperCase()}] ${message.timestamp} ${message.message}`
);
}
} else if (message.type === 'init') {
await app.migrate();
await app.init();
let count = 0;
while (!appInitialized.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
count++;
if (count > 100) {
throw new Error('App initialization timed out');
}
}
sendMessage({ type: 'init_result' });
} else if (message.type === 'mutation') {
const result = await app.mediator.executeMutation(message.input);
if (!app.current) {
return;
}
const result = await app.current.mediator.executeMutation(message.input);
sendMessage({
type: 'mutation_result',
mutationId: message.mutationId,
result,
});
} else if (message.type === 'query') {
const result = await app.mediator.executeQuery(message.input);
if (!app.current) {
return;
}
const result = await app.current.mediator.executeQuery(message.input);
sendMessage({ type: 'query_result', queryId: message.queryId, result });
} else if (message.type === 'query_and_subscribe') {
const result = await app.mediator.executeQueryAndSubscribe(
if (!app.current) {
return;
}
const result = await app.current.mediator.executeQueryAndSubscribe(
message.key,
message.windowId,
message.input
@@ -61,7 +130,11 @@ export const App = () => {
result,
});
} else if (message.type === 'query_unsubscribe') {
app.mediator.unsubscribeQuery(message.key, message.windowId);
if (!app.current) {
return;
}
app.current.mediator.unsubscribeQuery(message.key, message.windowId);
} else if (message.type === 'event') {
eventBus.publish(message.event);
}

View File

@@ -0,0 +1,51 @@
import { Asset } from 'expo-asset';
import { Directory, File } from 'expo-file-system';
import { PathService } from '@colanode/client/services';
import emojisDatabaseAsset from '../../assets/emojis.db';
import neotraxFontAsset from '../../assets/fonts/neotrax.otf';
import iconsDatabaseAsset from '../../assets/icons.db';
import indexHtmlAsset from '../../assets/ui/index.html';
export {
indexHtmlAsset,
emojisDatabaseAsset,
iconsDatabaseAsset,
neotraxFontAsset,
};
export const copyAssets = async (paths: PathService) => {
try {
const assetsDir = new Directory(paths.assets);
assetsDir.create({ intermediates: true, idempotent: true });
const fontsDir = new Directory(paths.fonts);
fontsDir.create({ intermediates: true, idempotent: true });
await copyAsset(
Asset.fromModule(emojisDatabaseAsset),
paths.emojisDatabase
);
await copyAsset(Asset.fromModule(iconsDatabaseAsset), paths.iconsDatabase);
await copyAsset(
Asset.fromModule(neotraxFontAsset),
paths.font('neotrax.otf')
);
} catch (error) {
console.error(error);
}
};
export const copyAsset = async (asset: Asset, path: string) => {
await asset.downloadAsync();
const localUri = asset.localUri ?? asset.uri;
const dest = new File(path);
if (dest.exists) {
dest.delete();
}
const assetFile = new File(localUri);
assetFile.copy(dest);
};

View File

@@ -1,18 +0,0 @@
import { modelName } from 'expo-device';
import { AppMeta, AppService } from '@colanode/client/services';
import { MobileFileSystem } from '@colanode/mobile/services/file-system';
import { MobileKyselyService } from '@colanode/mobile/services/kysely-service';
import { MobilePathService } from '@colanode/mobile/services/path-service';
const appMeta: AppMeta = {
type: 'mobile',
platform: modelName ?? 'unknown',
};
export const app = new AppService(
appMeta,
new MobileFileSystem(),
new MobileKyselyService(),
new MobilePathService()
);

View File

@@ -18,6 +18,7 @@ import {
import { KyselyBuildOptions, KyselyService } from '@colanode/client/services';
import { MobileFileSystem } from '@colanode/mobile/services/file-system';
import { MobilePathService } from '@colanode/mobile/services/path-service';
export class MobileKyselyService implements KyselyService {
private readonly fs = new MobileFileSystem();
@@ -87,9 +88,17 @@ class ExpoSqliteDriver implements Driver {
class ExpoSqliteConnection implements DatabaseConnection {
private readonly database: SQLiteDatabase;
private readonly options: KyselyBuildOptions;
private readonly paths: MobilePathService = new MobilePathService();
constructor(options: KyselyBuildOptions) {
this.database = openDatabaseSync(options.path);
const databaseName = this.paths.filename(options.path);
const databaseDirectory = this.paths.dirname(options.path);
this.database = openDatabaseSync(
databaseName,
undefined,
databaseDirectory
);
this.options = options;
}

View File

@@ -1,66 +1,65 @@
import { Paths } from 'expo-file-system';
import { Paths, File, Directory } from 'expo-file-system';
import { PathService } from '@colanode/client/services';
export class MobilePathService implements PathService {
private readonly appPath = Paths.document.uri;
private readonly appDatabasePath = this.join(this.appPath, 'app.db');
private readonly accountsDirectoryPath = this.join(this.appPath, 'accounts');
private readonly accountsDirectoryPath = new Directory(
Paths.document,
'accounts'
);
private getAccountDirectoryPath(accountId: string): string {
return this.join(this.accountsDirectoryPath, accountId);
return new Directory(this.accountsDirectoryPath, accountId).uri;
}
private getWorkspaceDirectoryPath(
accountId: string,
workspaceId: string
): string {
return this.join(
return new Directory(
this.getAccountDirectoryPath(accountId),
'workspaces',
workspaceId
);
).uri;
}
private getWorkspaceFilesDirectoryPath(
accountId: string,
workspaceId: string
): string {
return this.join(
return new Directory(
this.getWorkspaceDirectoryPath(accountId, workspaceId),
'files'
);
).uri;
}
private getAccountAvatarsDirectoryPath(accountId: string): string {
return this.join(this.getAccountDirectoryPath(accountId), 'avatars');
return new Directory(this.getAccountDirectoryPath(accountId), 'avatars')
.uri;
}
private getAssetsSourcePath(): string {
// In React Native/Expo, we should copy bundled assets to document directory
// for file system access, or use Asset.fromModule for bundled assets
// For now, we'll use a path in the document directory where assets will be copied
return this.join(this.appPath, 'bundled-assets');
return new Directory(Paths.document, 'assets').uri;
}
public get app(): string {
return this.appPath;
return Paths.document.uri;
}
public get appDatabase(): string {
return this.appDatabasePath;
return new File(Paths.document, 'app.db').uri;
}
public get accounts(): string {
return this.accountsDirectoryPath;
return this.accountsDirectoryPath.uri;
}
public get temp(): string {
return this.join(this.appPath, 'temp');
return new Directory(Paths.document, 'temp').uri;
}
public tempFile(name: string): string {
return this.join(this.appPath, 'temp', name);
return new File(Paths.document, 'temp', name).uri;
}
public account(accountId: string): string {
@@ -68,7 +67,7 @@ export class MobilePathService implements PathService {
}
public accountDatabase(accountId: string): string {
return this.join(this.getAccountDirectoryPath(accountId), 'account.db');
return new File(this.getAccountDirectoryPath(accountId), 'account.db').uri;
}
public workspace(accountId: string, workspaceId: string): string {
@@ -76,10 +75,10 @@ export class MobilePathService implements PathService {
}
public workspaceDatabase(accountId: string, workspaceId: string): string {
return this.join(
return new File(
this.getWorkspaceDirectoryPath(accountId, workspaceId),
'workspace.db'
);
).uri;
}
public workspaceFiles(accountId: string, workspaceId: string): string {
@@ -92,10 +91,10 @@ export class MobilePathService implements PathService {
fileId: string,
extension: string
): string {
return this.join(
return new File(
this.getWorkspaceFilesDirectoryPath(accountId, workspaceId),
fileId + extension
);
).uri;
}
public accountAvatars(accountId: string): string {
@@ -103,62 +102,30 @@ export class MobilePathService implements PathService {
}
public accountAvatar(accountId: string, avatarId: string): string {
return this.join(
return new File(
this.getAccountAvatarsDirectoryPath(accountId),
avatarId + '.jpeg'
);
).uri;
}
public dirname(dir: string): string {
// Remove trailing slash if present
const normalizedPath = dir.replace(/\/+$/, '');
const lastSlashIndex = normalizedPath.lastIndexOf('/');
if (lastSlashIndex === -1) {
return '.';
public dirname(path: string): string {
const info = Paths.info(path);
if (info.isDirectory) {
return path;
}
if (lastSlashIndex === 0) {
return '/';
}
return normalizedPath.substring(0, lastSlashIndex);
const file = new File(path);
return file.parentDirectory.uri;
}
public filename(file: string): string {
const basename = file.substring(file.lastIndexOf('/') + 1);
const lastDotIndex = basename.lastIndexOf('.');
if (lastDotIndex === -1 || lastDotIndex === 0) {
return basename;
}
return basename.substring(0, lastDotIndex);
}
public join(...paths: string[]): string {
if (paths.length === 0) return '.';
// Filter out empty strings and normalize paths
const normalizedPaths = paths
.filter((path) => path && path.length > 0)
.map((path) => path.replace(/\/+$/, '')); // Remove trailing slashes
if (normalizedPaths.length === 0) return '.';
// Join with single slashes
const result = normalizedPaths.join('/');
// Handle absolute paths (starting with /)
if (paths[0] && paths[0].startsWith('/')) {
return '/' + result.replace(/^\/+/, '');
}
return result.replace(/\/+/g, '/'); // Replace multiple slashes with single slash
public filename(path: string): string {
const file = new File(path);
return file.name;
}
public extension(name: string): string {
const basename = name.substring(name.lastIndexOf('/') + 1);
const lastDotIndex = basename.lastIndexOf('.');
if (lastDotIndex === -1 || lastDotIndex === 0) {
return '';
}
return basename.substring(lastDotIndex);
const file = new File(name);
return file.extension;
}
public get assets(): string {
@@ -166,14 +133,18 @@ export class MobilePathService implements PathService {
}
public get fonts(): string {
return this.join(this.getAssetsSourcePath(), 'fonts');
return new Directory(this.getAssetsSourcePath(), 'fonts').uri;
}
public get emojisDatabase(): string {
return this.join(this.getAssetsSourcePath(), 'emojis.db');
return new File(this.getAssetsSourcePath(), 'emojis.db').uri;
}
public get iconsDatabase(): string {
return this.join(this.getAssetsSourcePath(), 'icons.db');
return new File(this.getAssetsSourcePath(), 'icons.db').uri;
}
public font(name: string): string {
return new File(this.getAssetsSourcePath(), 'fonts', name).uri;
}
}

View File

@@ -72,8 +72,9 @@ export class WebFileSystem implements FileSystem {
//Ensure the data type is compatible with the File Write API
private ensureArrayBuffer(data: Uint8Array): ArrayBuffer {
const arrayBuffer: ArrayBuffer =
data.buffer instanceof ArrayBuffer ?
data.buffer : new ArrayBuffer(data.byteLength);
data.buffer instanceof ArrayBuffer
? data.buffer
: new ArrayBuffer(data.byteLength);
if (!(data.buffer instanceof ArrayBuffer)) {
const view = new Uint8Array<ArrayBuffer>(arrayBuffer);

View File

@@ -101,4 +101,8 @@ export class WebPathService implements PathService {
const parts = path.split('.');
return parts.length > 1 ? '.' + parts[parts.length - 1] : '';
}
public font(name: string): string {
return this.join(this.assetsSourcePath, 'fonts', name);
}
}

View File

@@ -19,6 +19,7 @@ export class AssetService {
path: this.app.path.emojisDatabase,
readonly: true,
});
this.icons = this.app.kysely.build<IconDatabaseSchema>({
path: this.app.path.iconsDatabase,
readonly: true,

View File

@@ -19,10 +19,10 @@ export interface PathService {
accountAvatar: (accountId: string, avatarId: string) => string;
dirname: (path: string) => string;
filename: (path: string) => string;
join: (...paths: string[]) => string;
extension: (path: string) => string;
assets: string;
fonts: string;
emojisDatabase: string;
iconsDatabase: string;
font: (name: string) => string;
}

View File

@@ -389,7 +389,12 @@ export class FileService {
}
private buildFilePath(id: string, extension: string): string {
return this.app.path.join(this.filesDir, `${id}${extension}`);
return this.app.path.workspaceFile(
this.workspace.accountId,
this.workspace.id,
id,
extension
);
}
public async cleanupFiles(): Promise<void> {
@@ -423,7 +428,15 @@ export class FileService {
continue;
}
const filePath = this.app.path.join(this.filesDir, fileIdMap[fileId]!);
const fsFile = fileIdMap[fileId]!;
const name = this.app.path.filename(fsFile);
const extension = this.app.path.extension(fsFile);
const filePath = this.app.path.workspaceFile(
this.workspace.accountId,
this.workspace.id,
name,
extension
);
await this.app.fs.delete(filePath);
}
}

View File

@@ -16,7 +16,7 @@ export const Login = () => {
return (
<div className="grid h-screen min-h-screen w-full grid-cols-1 lg:grid-cols-5">
<div className="items-center justify-center bg-foreground hidden lg:flex">
<div className="items-center justify-center bg-foreground hidden lg:flex lg:col-span-2">
<h1 className="font-neotrax text-6xl text-background">colanode</h1>
</div>
<div className="flex items-center justify-center py-12 lg:col-span-3">

View File

@@ -18,13 +18,23 @@ export const App = ({ type }: AppProps) => {
const [initialized, setInitialized] = useState(false);
const [openLogin, setOpenLogin] = useState(false);
const appMetadataListQuery = useLiveQuery({
type: 'app.metadata.list',
});
const appMetadataListQuery = useLiveQuery(
{
type: 'app.metadata.list',
},
{
enabled: initialized,
}
);
const accountListQuery = useLiveQuery({
type: 'account.list',
});
const accountListQuery = useLiveQuery(
{
type: 'account.list',
},
{
enabled: initialized,
}
);
useEffect(() => {
window.colanode.init().then(() => {

View File

@@ -20,6 +20,7 @@ const FONTS_OTF_PATH = path.resolve(FONTS_DIR, NEOTRAX_FONT_NAME);
const DESKTOP_ASSETS_DIR = path.resolve('apps', 'desktop', 'assets');
const WEB_ASSETS_DIR = path.resolve('apps', 'web', 'public', 'assets');
const MOBILE_ASSETS_DIR = path.resolve('apps', 'mobile', 'assets');
const copyFile = (source: string, target: string | string[]) => {
if (!fs.existsSync(source)) {
@@ -40,24 +41,19 @@ const copyFile = (source: string, target: string | string[]) => {
const execute = () => {
copyFile(EMOJIS_DB_PATH, path.resolve(DESKTOP_ASSETS_DIR, 'emojis.db'));
copyFile(EMOJIS_DB_PATH, path.resolve(MOBILE_ASSETS_DIR, 'emojis.db'));
copyFile(EMOJIS_MIN_DB_PATH, path.resolve(WEB_ASSETS_DIR, 'emojis.db'));
copyFile(EMOJI_SVG_PATH, [
path.resolve(DESKTOP_ASSETS_DIR, 'emojis.svg'),
path.resolve(WEB_ASSETS_DIR, 'emojis.svg'),
]);
copyFile(EMOJI_SVG_PATH, path.resolve(WEB_ASSETS_DIR, 'emojis.svg'));
copyFile(ICONS_DB_PATH, path.resolve(DESKTOP_ASSETS_DIR, 'icons.db'));
copyFile(ICONS_DB_PATH, path.resolve(MOBILE_ASSETS_DIR, 'icons.db'));
copyFile(ICONS_MIN_DB_PATH, path.resolve(WEB_ASSETS_DIR, 'icons.db'));
copyFile(ICONS_SVG_PATH, [
path.resolve(DESKTOP_ASSETS_DIR, 'icons.svg'),
path.resolve(WEB_ASSETS_DIR, 'icons.svg'),
]);
copyFile(ICONS_SVG_PATH, path.resolve(WEB_ASSETS_DIR, 'icons.svg'));
copyFile(FONTS_OTF_PATH, [
path.resolve(DESKTOP_ASSETS_DIR, 'fonts', NEOTRAX_FONT_NAME),
path.resolve(WEB_ASSETS_DIR, 'fonts', NEOTRAX_FONT_NAME),
path.resolve(MOBILE_ASSETS_DIR, 'fonts', NEOTRAX_FONT_NAME),
]);
copyFile(