Fix Obsidian plugin verification issues, deprecated APIs, popout compatibility, any types, and switch fetch to requestUrl

This commit is contained in:
zoorpha 2026-06-19 00:18:52 +02:00
parent b7021c1367
commit b378248d73
14 changed files with 160 additions and 114 deletions

View file

@ -1,6 +1,6 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { builtinModules } from "module";
const banner =
`/*
@ -22,7 +22,7 @@ const context = await esbuild.context({
'electron',
'@codemirror/*',
'lezer',
...builtins],
...builtinModules],
format: 'cjs',
target: 'es2018',
logLevel: "info",

View file

@ -3,7 +3,7 @@
"name": "RustShare Vault Sync",
"version": "0.1.0",
"minAppVersion": "0.15.0",
"description": "Sync local Obsidian vaults to RustShare. Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.",
"description": "Sync local vaults to RustShare. RustShare is not affiliated with, endorsed by, or sponsored by Dynalist Inc.",
"author": "RustShare",
"authorUrl": "https://rustshare.io",
"fundingUrl": "",

14
package-lock.json generated
View file

@ -11,7 +11,6 @@
"devDependencies": {
"@types/node": "^20.0.0",
"@vitest/coverage-v8": "^4.1.8",
"builtin-modules": "4.0.0",
"esbuild": "0.24.0",
"obsidian": "1.7.2",
"tslib": "2.8.0",
@ -1123,19 +1122,6 @@
"js-tokens": "^10.0.0"
}
},
"node_modules/builtin-modules": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz",
"integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",

View file

@ -1,7 +1,7 @@
{
"name": "rustshare-vault-sync",
"version": "0.1.0",
"description": "RustShare Vault Sync for Obsidian. Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.",
"description": "Sync local vaults to RustShare. RustShare is not affiliated with, endorsed by, or sponsored by Dynalist Inc.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
@ -20,7 +20,6 @@
"devDependencies": {
"@types/node": "^20.0.0",
"@vitest/coverage-v8": "^4.1.8",
"builtin-modules": "4.0.0",
"esbuild": "0.24.0",
"obsidian": "1.7.2",
"tslib": "2.8.0",

View file

@ -1,5 +1,15 @@
// Disclaimer: Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.
// RustShare Vault Sync — API client for the RustShare vault sync backend.
import { requestUrl } from 'obsidian';
export interface APIError extends Error {
status?: number;
retry_after?: number;
error?: string;
path?: string;
client_rev?: number;
current_rev?: number;
server_sha256?: string;
resolution?: string;
}
export interface Vault {
id: string;
@ -130,14 +140,6 @@ export class RustShareAPI {
}
}
private fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 30000): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...init, signal: controller.signal }).finally(() => {
clearTimeout(timeout);
});
}
private async request<T>(method: string, endpoint: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<T>;
private async request(method: string, endpoint: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<unknown> {
const headers: Record<string, string> = {};
@ -149,18 +151,27 @@ export class RustShareAPI {
}
Object.assign(headers, extraHeaders);
if (body !== undefined && !(body instanceof ArrayBuffer) && !headers['Content-Type']) {
const contentType = headers['Content-Type'];
if (body !== undefined && !(body instanceof ArrayBuffer) && !contentType) {
headers['Content-Type'] = 'application/json';
}
const response = await this.fetchWithTimeout(this.buildUrl(endpoint), {
const response = await requestUrl({
url: this.buildUrl(endpoint),
method,
headers,
body: body instanceof ArrayBuffer ? body : body !== undefined ? JSON.stringify(body) : undefined,
contentType: contentType || (body !== undefined && !(body instanceof ArrayBuffer) ? 'application/json' : undefined),
throw: false,
});
const headersLower = Object.keys(response.headers).reduce((acc, key) => {
acc[key.toLowerCase()] = response.headers[key];
return acc;
}, {} as Record<string, string>);
if (response.status === 409) {
const conflict = await response.json().catch(() => ({})) as Partial<ConflictError>;
const conflict = (response.json || {}) as Partial<ConflictError>;
const error: ConflictError = {
error: 'conflict',
message: conflict.message ?? 'Conflict detected',
@ -170,30 +181,30 @@ export class RustShareAPI {
server_sha256: conflict.server_sha256,
resolution: conflict.resolution ?? 'create_conflict_copy',
};
const err = new Error(error.message || 'Conflict');
const err = new Error(error.message || 'Conflict') as APIError;
Object.assign(err, error);
throw err;
}
if (response.status === 429) {
const retryAfter = response.headers.get('retry-after') || response.headers.get('Retry-After');
const retryAfter = headersLower['retry-after'];
let retryAfterSeconds: number | undefined;
if (retryAfter) {
const trimmed = retryAfter.trim();
// Only parse if it's a pure integer (delta-seconds). Ignore HTTP-date strings.
if (/^\d+$/.test(trimmed)) {
retryAfterSeconds = parseInt(trimmed, 10);
}
}
const text = await response.text();
const err = new Error(`HTTP 429: ${text}`);
(err as any).status = 429;
(err as any).retry_after = retryAfterSeconds;
const text = response.text || '';
const err = new Error(`HTTP 429: ${text}`) as APIError;
err.status = 429;
err.retry_after = retryAfterSeconds;
throw err;
}
if (!response.ok) {
const text = await response.text().catch(() => 'Unknown error');
const isOk = response.status >= 200 && response.status < 300;
if (!isOk) {
const text = response.text || 'Unknown error';
throw new Error(`HTTP ${response.status}: ${text}`);
}
@ -201,12 +212,12 @@ export class RustShareAPI {
return undefined;
}
const contentType = response.headers.get('Content-Type') ?? '';
if (contentType.includes('application/json')) {
return response.json();
const contentTypeHeader = headersLower['content-type'] ?? '';
if (contentTypeHeader.includes('application/json')) {
return response.json as unknown;
}
return response.arrayBuffer();
return response.arrayBuffer as unknown;
}
// Device pairing methods

View file

@ -1,8 +1,8 @@
// Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.
// This file is part of RustShare Vault Sync.
import { Plugin, Notice, TFile } from 'obsidian';
import { RustShareVaultSyncSettings, DEFAULT_SETTINGS, validateSettings } from './settings';
import { Plugin, Notice, TFile, Platform, FileSystemAdapter } from 'obsidian';
import { RustShareVaultSyncSettings, DEFAULT_SETTINGS } from './settings';
import { RustShareVaultSyncSettingTab } from './ui/settings-tab';
import { StatusBar } from './ui/status-bar';
import { RustShareAPI } from './api';
@ -10,7 +10,7 @@ import { SyncEngine } from './sync';
import { SyncState, createEmptySyncState, migrateSyncState, pruneTombstones } from './state';
import { SyncQueue, SyncOperation } from './sync-queue';
import { syncLog } from './sync-log';
import { generateDeviceId, detectCloudSyncFolder, shouldIgnorePath } from './utils';
import { detectCloudSyncFolder, shouldIgnorePath } from './utils';
export default class RustShareVaultSyncPlugin extends Plugin {
declare settings: RustShareVaultSyncSettings;
@ -25,7 +25,19 @@ export default class RustShareVaultSyncPlugin extends Plugin {
// Generate device name if not set
if (!this.settings.deviceName) {
this.settings.deviceName = `${this.app.vault.getName()} - ${navigator.platform}`;
let osName = 'Unknown OS';
if (Platform.isMacOS) {
osName = 'macOS';
} else if (Platform.isWin) {
osName = 'Windows';
} else if (Platform.isLinux) {
osName = 'Linux';
} else if (Platform.isIosApp) {
osName = 'iOS';
} else if (Platform.isAndroidApp) {
osName = 'Android';
}
this.settings.deviceName = `${this.app.vault.getName()} - ${osName}`;
await this.saveSettings();
}
@ -56,7 +68,7 @@ export default class RustShareVaultSyncPlugin extends Plugin {
// Ribbon icon
this.addRibbonIcon('cloud', 'RustShare Vault Sync', (evt: MouseEvent) => {
this.runManualSync();
void this.runManualSync();
});
// Commands
@ -120,7 +132,10 @@ export default class RustShareVaultSyncPlugin extends Plugin {
});
// Check for double-sync warning
const vaultPath = (this.app.vault.adapter as any).getBasePath?.() || '';
let vaultPath = '';
if (this.app.vault.adapter instanceof FileSystemAdapter) {
vaultPath = this.app.vault.adapter.getBasePath();
}
const cloudFolder = detectCloudSyncFolder(vaultPath);
if (cloudFolder) {
new Notice(
@ -136,10 +151,10 @@ export default class RustShareVaultSyncPlugin extends Plugin {
}
async loadSettings() {
const data = await this.loadData();
const data = (await this.loadData()) as Record<string, unknown> | null;
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
if (data?.syncState) {
this.syncState = migrateSyncState(data.syncState);
if (data && data.syncState && typeof data.syncState === 'object') {
this.syncState = migrateSyncState(data.syncState as Record<string, unknown>);
}
}
@ -213,9 +228,10 @@ export default class RustShareVaultSyncPlugin extends Plugin {
deviceId = resp.id;
this.settings.deviceId = deviceId;
await this.saveSettings();
} catch (e: any) {
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
const isNetworkError = e instanceof TypeError ||
/fetch|network|Failed to fetch/i.test(e?.message || '');
/fetch|network|Failed to fetch/i.test(message);
if (this.settings.deviceId) {
deviceId = this.settings.deviceId;
@ -225,7 +241,7 @@ export default class RustShareVaultSyncPlugin extends Plugin {
console.warn('Using cached device ID, registration failed:', e);
}
} else {
throw new Error(`Failed to register device: ${e.message || e}`);
throw new Error(`Failed to register device: ${message}`);
}
}
@ -282,7 +298,7 @@ export default class RustShareVaultSyncPlugin extends Plugin {
}
const api = new RustShareAPI(this.settings.rustshareUrl, this.settings.authToken);
const engine = new SyncEngine(this.app.vault, api, this.syncState, this.settings.deviceName);
const engine = new SyncEngine(this.app, api, this.syncState, this.settings.deviceName);
try {
this.statusBar.updateStatus('syncing', 'Syncing...');
@ -321,7 +337,7 @@ export default class RustShareVaultSyncPlugin extends Plugin {
this.isSyncing = true;
try {
const api = new RustShareAPI(this.settings.rustshareUrl, this.settings.authToken);
const engine = new SyncEngine(this.app.vault, api, this.syncState, this.settings.deviceName);
const engine = new SyncEngine(this.app, api, this.syncState, this.settings.deviceName);
this.statusBar.updateStatus('syncing', 'Syncing...');
const result = await engine.syncIncremental(ops);
@ -355,7 +371,9 @@ export default class RustShareVaultSyncPlugin extends Plugin {
private startAutoSync(): void {
if (this.syncInterval) return;
const ms = this.settings.autoSyncIntervalMinutes * 60 * 1000;
this.syncInterval = window.setInterval(() => this.runManualSync(), ms);
this.syncInterval = window.setInterval(() => {
void this.runManualSync();
}, ms);
}
private stopAutoSync(): void {

View file

@ -34,22 +34,26 @@ export function createEmptySyncState(vaultId: string, deviceId: string, deviceNa
};
}
export function migrateSyncState(state: Partial<SyncState> & Record<string, any>): SyncState {
const version = state.version ?? 0;
export function migrateSyncState(state: Record<string, unknown>): SyncState {
const version = typeof state.version === 'number' ? state.version : 0;
if (version === 1) {
// Current version, return as-is with defaults
return {
version: 1,
vault_id: state.vault_id || '',
device_id: state.device_id || '',
device_name: state.device_name || '',
last_server_rev: state.last_server_rev ?? 0,
files: state.files || {},
tombstones: state.tombstones || {},
vault_id: typeof state.vault_id === 'string' ? state.vault_id : '',
device_id: typeof state.device_id === 'string' ? state.device_id : '',
device_name: typeof state.device_name === 'string' ? state.device_name : '',
last_server_rev: typeof state.last_server_rev === 'number' ? state.last_server_rev : 0,
files: (state.files && typeof state.files === 'object') ? (state.files as Record<string, LocalFileState>) : {},
tombstones: (state.tombstones && typeof state.tombstones === 'object') ? (state.tombstones as Record<string, TombstoneState>) : {},
};
}
// Future migrations go here
return createEmptySyncState(state.vault_id || '', state.device_id || '', state.device_name || '');
return createEmptySyncState(
typeof state.vault_id === 'string' ? state.vault_id : '',
typeof state.device_id === 'string' ? state.device_id : '',
typeof state.device_name === 'string' ? state.device_name : ''
);
}
const TOMBSTONE_RETENTION_DAYS = 30;

View file

@ -113,12 +113,13 @@ export class SyncQueue {
try {
await this.onSync(ops);
this.retryCount = 0;
} catch (error: any) {
if (error?.status === 409) {
} catch (error) {
const apiError = error as { status?: number; retry_after?: number } | null;
if (apiError?.status === 409) {
throw error;
}
if (error?.status === 429 && typeof error?.retry_after === 'number' && !Number.isNaN(error.retry_after)) {
this.lastRetryAfterMs = error.retry_after * 1000;
if (apiError?.status === 429 && typeof apiError.retry_after === 'number' && !Number.isNaN(apiError.retry_after)) {
this.lastRetryAfterMs = apiError.retry_after * 1000;
}
if (this.isRetryableError(error)) {
this.online = false;
@ -192,7 +193,9 @@ export class SyncQueue {
return true;
}
// 429 Too Many Requests is a retryable network condition
if ((error as any).status === 429) {
// 429 Too Many Requests is a retryable network condition
const status = (error as { status?: number }).status;
if (status === 429) {
return true;
}
}
@ -201,11 +204,12 @@ export class SyncQueue {
private isRetryableError(error: unknown): boolean {
if (this.isNetworkError(error)) return true;
const status = (error as any)?.status;
const apiError = error as { status?: number; message?: string } | null;
const status = apiError?.status;
if (status === 429) return true;
if (typeof status === 'number' && status >= 500 && status < 600) return true;
// Also check message prefix for HTTP status codes
const msg = (error as any)?.message || '';
const msg = apiError?.message || '';
if (/^HTTP 429:/.test(msg)) return true;
if (/^HTTP 5\d\d:/.test(msg)) return true;
return false;

View file

@ -1,11 +1,11 @@
// Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.
// This file is part of RustShare Vault Sync.
import { Vault, TFile, normalizePath } from 'obsidian';
import { Vault, TFile, normalizePath, App } from 'obsidian';
import { RustShareAPI, VaultManifestEntry } from './api';
import { SyncState } from './state';
import { sha256ArrayBuffer, formatConflictFileName, shouldIgnorePath } from './utils';
import { SyncOperation, SyncOperationType } from './sync-queue';
import { SyncOperation } from './sync-queue';
import { syncLog } from './sync-log';
export interface SyncResult {
@ -26,13 +26,15 @@ class UploadConflictError extends Error {
}
}
export class SyncEngine {
private vault: Vault;
constructor(
private vault: Vault,
private app: App,
private api: RustShareAPI,
private state: SyncState,
private deviceName: string,
) {}
) {
this.vault = app.vault;
}
async sync(): Promise<SyncResult> {
// 1. Scan local files
@ -94,8 +96,7 @@ export class SyncEngine {
if (!remote) {
// New file — upload
try {
const resp = await this.uploadFile(path, 0);
// resp.server_rev is authoritative
await this.uploadFile(path, 0);
result.uploaded++;
} catch (e) {
if (e instanceof UploadConflictError) {
@ -153,8 +154,7 @@ export class SyncEngine {
} else if (remote.sha256 === localState.sha256) {
// Local changed, remote unchanged — upload
try {
const resp = await this.uploadFile(path, remote.server_rev);
// resp.server_rev is authoritative
await this.uploadFile(path, remote.server_rev);
result.uploaded++;
} catch (e) {
if (e instanceof UploadConflictError) {
@ -331,11 +331,12 @@ export class SyncEngine {
last_synced_at: new Date().toISOString(),
};
result.uploaded++;
} catch (e: any) {
if (e.error === 'conflict') {
const serverSha256 = e.server_sha256;
} catch (e) {
const apiError = e as { error?: string; server_sha256?: string; current_rev?: number } | null;
if (apiError && apiError.error === 'conflict') {
const serverSha256 = apiError.server_sha256;
try {
await this.handleUploadConflict(op.path, e.current_rev ?? remote.server_rev, serverSha256);
await this.handleUploadConflict(op.path, apiError.current_rev ?? remote.server_rev, serverSha256);
result.conflicts++;
} catch (conflictErr) {
result.errors.push(`Conflict resolution failed: ${op.path}: ${conflictErr}`);
@ -383,7 +384,7 @@ export class SyncEngine {
const batch = allFiles.slice(i, i + batchSize);
await Promise.all(batch.map(async (file) => {
const path = file.path;
if (shouldIgnorePath(path)) return;
if (shouldIgnorePath(path, this.vault.configDir)) return;
if (file instanceof TFile) {
const buffer = await this.vault.readBinary(file);
const hash = await sha256ArrayBuffer(buffer);
@ -391,7 +392,7 @@ export class SyncEngine {
}
}));
// Yield to event loop
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => window.setTimeout(resolve, 0));
}
return files;
}
@ -406,9 +407,10 @@ export class SyncEngine {
let resp: { server_rev: number };
try {
resp = await this.api.uploadFile(this.state.vault_id, path, buffer, hash, baseServerRev, this.state.device_id);
} catch (e: any) {
if (e.error === 'conflict') {
throw new UploadConflictError(path, e.server_sha256, e.current_rev);
} catch (e) {
const apiError = e as { error?: string; server_sha256?: string; current_rev?: number } | null;
if (apiError && apiError.error === 'conflict') {
throw new UploadConflictError(path, apiError.server_sha256, apiError.current_rev);
}
throw e;
}
@ -449,7 +451,7 @@ export class SyncEngine {
private async deleteLocalFile(path: string, remoteServerRev?: number): Promise<void> {
const file = this.vault.getAbstractFileByPath(normalizePath(path));
if (!(file instanceof TFile)) return;
await this.vault.delete(file);
await this.app.fileManager.trashFile(file);
delete this.state.files[path];
if (remoteServerRev !== undefined) {
this.state.tombstones[path] = { deleted_at: new Date().toISOString(), server_rev: remoteServerRev };

View file

@ -13,7 +13,9 @@ export class RustShareVaultSyncSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'RustShare Vault Sync Settings' });
new Setting(containerEl)
.setName('RustShare Vault Sync Settings')
.setHeading();
// Disclaimer
containerEl.createEl('p', {
@ -71,7 +73,9 @@ export class RustShareVaultSyncSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
containerEl.createEl('h3', { text: 'Advanced Sync Settings' });
new Setting(containerEl)
.setName('Advanced Sync Settings')
.setHeading();
new Setting(containerEl)
.setName('Save debounce (ms)')

View file

@ -1,7 +1,7 @@
// Obsidian is a trademark of Dynalist Inc. RustShare is not affiliated with, endorsed by, or sponsored by Obsidian.
// This file is part of RustShare Vault Sync.
import { Plugin, setIcon } from 'obsidian';
import { Plugin } from 'obsidian';
type SyncStatus = 'disconnected' | 'connected' | 'syncing' | 'synced' | 'conflict' | 'error' | 'offline';
@ -19,8 +19,8 @@ export class StatusBar {
this.currentStatus = status;
this.statusBarItemEl.empty();
const iconEl = this.statusBarItemEl.createEl('span', { cls: 'rustshare-sync-status-icon' });
const textEl = this.statusBarItemEl.createEl('span', { text: message || this.statusText(status) });
this.statusBarItemEl.createEl('span', { cls: 'rustshare-sync-status-icon' });
this.statusBarItemEl.createEl('span', { text: message || this.statusText(status) });
this.statusBarItemEl.removeClass(
'rustshare-sync-status--disconnected',

View file

@ -32,17 +32,17 @@ export function formatConflictFileName(originalPath: string, deviceName: string,
return `${dir}${name} (RustShare conflicted copy ${safeDeviceName} ${timestamp})${ext}`;
}
export const DEFAULT_IGNORED_PATHS = [
'.obsidian/',
'.git/',
'.DS_Store',
'node_modules/',
'Thumbs.db',
'.rustshare-sync-state.json',
];
export function shouldIgnorePath(path: string): boolean {
for (const ignored of DEFAULT_IGNORED_PATHS) {
export function shouldIgnorePath(path: string, configDir = '.obsidian'): boolean {
const normalizedConfigDir = configDir.endsWith('/') ? configDir : configDir + '/';
const ignoredPaths = [
normalizedConfigDir,
'.git/',
'.DS_Store',
'node_modules/',
'Thumbs.db',
'.rustshare-sync-state.json',
];
for (const ignored of ignoredPaths) {
if (ignored.endsWith('/')) {
// Directory patterns: match at start or after a slash
if (path.startsWith(ignored) || path.includes('/' + ignored)) return true;

View file

@ -26,6 +26,7 @@ export function normalizePath(path: string): string {
}
export class Vault {
configDir = '.obsidian';
private files = new Map<string, TAbstractFile>();
private contents = new Map<string, ArrayBuffer>();
@ -90,8 +91,23 @@ export class Vault {
}
}
export class FileManager {
constructor(private vault: Vault) {}
async trashFile(file: TAbstractFile): Promise<void> {
await this.vault.delete(file);
}
}
export class App {
vault: Vault;
fileManager: FileManager;
constructor(vault: Vault) {
this.vault = vault;
this.fileManager = new FileManager(vault);
}
}
export class Plugin {}
export class Notice {}
export class App {}
export class PluginSettingTab {}
export class Setting {}

View file

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Vault, TFile } from './mocks/obsidian';
import { Vault, TFile, App } from './mocks/obsidian';
import { SyncEngine } from '../src/sync';
import { createEmptySyncState, SyncState } from '../src/state';
import { VaultManifest, VaultManifestEntry } from '../src/api';
@ -105,15 +105,17 @@ class MockAPI {
describe('SyncEngine', () => {
let vault: Vault;
let app: App;
let api: MockAPI;
let state: SyncState;
let engine: SyncEngine;
beforeEach(() => {
vault = new Vault();
app = new App(vault);
api = new MockAPI();
state = createEmptySyncState('test-vault', 'device-123', 'test-device');
engine = new SyncEngine(vault, api as any, state, 'test-device');
engine = new SyncEngine(app as any, api as any, state, 'test-device');
});
describe('full sync', () => {