diff --git a/src/DeviceManager.ts b/src/DeviceManager.ts new file mode 100644 index 0000000..4d25242 --- /dev/null +++ b/src/DeviceManager.ts @@ -0,0 +1,180 @@ +"use strict"; + +import { Platform } from "obsidian"; +import type { LoginManager } from "./LoginManager"; +import { Observable } from "./observable/Observable"; + +const DEVICE_ID_KEY = "relay-device-id"; + +/** + * Generate a PocketBase-compatible ID. + * Format: 15 characters, lowercase alphanumeric only. + */ +function generatePocketBaseId(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + const array = new Uint8Array(15); + crypto.getRandomValues(array); + return Array.from(array, (byte) => chars[byte % chars.length]).join(""); +} + +/** + * Get platform string for device registration. + */ +function getPlatform(): string { + if (Platform.isIosApp) return "Phone (iOS)"; + if (Platform.isAndroidApp) return "Phone (Android)"; + if (Platform.isMacOS) return "Desktop (macOS)"; + if (Platform.isWin) return "Desktop (Windows)"; + if (Platform.isLinux) return "Desktop (Linux)"; + if (Platform.isMobile) return "Mobile"; + return "Desktop"; +} + +export class DeviceManager extends Observable { + private deviceId: string | null = null; + private registered = false; + + constructor( + private appId: string, + private vaultName: string, + private loginManager: LoginManager, + ) { + super("DeviceManager"); + } + + /** + * Get or create the device ID from localStorage. + */ + getDeviceId(): string { + if (this.deviceId) return this.deviceId; + + let id = localStorage.getItem(DEVICE_ID_KEY); + if (!id) { + id = generatePocketBaseId(); + localStorage.setItem(DEVICE_ID_KEY, id); + this.log("Generated new device ID:", id); + } + this.deviceId = id; + return id; + } + + /** + * Get platform string. + */ + getPlatform(): string { + return getPlatform(); + } + + /** + * Register device and vault with PocketBase. + * Creates records if they don't exist, updates if they do. + */ + async register(): Promise { + if (this.registered) { + this.debug("Already registered this session"); + return; + } + + if (!this.loginManager.loggedIn) { + this.debug("Not logged in, skipping registration"); + return; + } + + const deviceId = this.getDeviceId(); + const platform = this.getPlatform(); + const userId = this.loginManager.user?.id; + + if (!userId) { + this.warn("No user ID available"); + return; + } + + try { + // Register device + await this.registerDevice(deviceId, platform, userId); + + // Register vault + await this.registerVault(this.appId, this.vaultName, deviceId, userId); + + this.registered = true; + this.log("Device and vault registered successfully"); + } catch (error) { + this.error("Failed to register device/vault:", error); + } + } + + private async registerDevice( + deviceId: string, + platform: string, + userId: string, + ): Promise { + const pb = this.loginManager.pb; + + try { + // Try to create new device record + await pb.collection("devices").create({ + id: deviceId, + name: platform, + platform: platform, + user: userId, + }); + this.log("Created new device record:", deviceId); + } catch (e: any) { + // Record may already exist, try to update + if (e.status === 400 || e.status === 409) { + try { + await pb.collection("devices").update(deviceId, { + platform: platform, + user: userId, + }); + this.log("Updated existing device record:", deviceId); + } catch (updateError) { + this.error("Failed to update device:", updateError); + throw updateError; + } + } else { + throw e; + } + } + } + + private async registerVault( + vaultId: string, + vaultName: string, + deviceId: string, + userId: string, + ): Promise { + const pb = this.loginManager.pb; + + try { + // Try to create new vault record + await pb.collection("vaults").create({ + id: vaultId, + name: vaultName, + device: deviceId, + user: userId, + }); + this.log("Created new vault record:", vaultId); + } catch (e: any) { + // Record may already exist, try to update + if (e.status === 400 || e.status === 409) { + try { + await pb.collection("vaults").update(vaultId, { + name: vaultName, + device: deviceId, + }); + this.log("Updated existing vault record:", vaultId); + } catch (updateError) { + this.error("Failed to update vault:", updateError); + throw updateError; + } + } else { + throw e; + } + } + } + + override destroy(): void { + super.destroy(); + } +} diff --git a/src/LoginManager.ts b/src/LoginManager.ts index 6b4e583..c7b9492 100644 --- a/src/LoginManager.ts +++ b/src/LoginManager.ts @@ -1,6 +1,6 @@ "use strict"; -import { requestUrl, type RequestUrlResponsePromise } from "obsidian"; +import { apiVersion, requestUrl, type RequestUrlResponsePromise } from "obsidian"; import { User } from "./User"; import PocketBase, { BaseAuthStore, @@ -13,7 +13,7 @@ import { Observable } from "./observable/Observable"; declare const GIT_TAG: string; -import { customFetch } from "./customFetch"; +import { customFetch, getDeviceManagementHeaders } from "./customFetch"; import { LocalAuthStore } from "./pocketbase/LocalAuthStore"; import type { TimeProvider } from "./TimeProvider"; import { FeatureFlagManager } from "./flagManager"; @@ -192,6 +192,7 @@ export class LoginManager extends Observable { options.fetch = customFetch; options.headers = Object.assign({}, options.headers, { "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, }); return { url, options }; }; @@ -283,6 +284,8 @@ export class LoginManager extends Observable { const headers = { Authorization: `Bearer ${this.pb.authStore.token}`, "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, + ...getDeviceManagementHeaders(), }; return requestUrl({ url: `${this.endpointManager.getApiUrl()}/relay/${relay_guid}/check-host`, @@ -295,6 +298,8 @@ export class LoginManager extends Observable { const headers = { Authorization: `Bearer ${this.pb.authStore.token}`, "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, + ...getDeviceManagementHeaders(), }; requestUrl({ url: `${this.endpointManager.getApiUrl()}/flags`, @@ -315,6 +320,7 @@ export class LoginManager extends Observable { whoami() { const headers = { Authorization: `Bearer ${this.pb.authStore.token}`, + ...getDeviceManagementHeaders(), }; requestUrl({ url: `${this.endpointManager.getApiUrl()}/whoami`, @@ -362,6 +368,7 @@ export class LoginManager extends Observable { options.fetch = customFetch; options.headers = Object.assign({}, options.headers, { "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, }); return { url, options }; }; diff --git a/src/NetworkStatus.ts b/src/NetworkStatus.ts index 36fdd60..090c1a3 100644 --- a/src/NetworkStatus.ts +++ b/src/NetworkStatus.ts @@ -1,6 +1,7 @@ -import { requestUrl } from "obsidian"; +import { apiVersion, requestUrl } from "obsidian"; import { curryLog } from "./debug"; import type { TimeProvider } from "./TimeProvider"; +import { getDeviceManagementHeaders } from "./customFetch"; declare const GIT_TAG: string; @@ -77,7 +78,11 @@ class NetworkStatus { return requestUrl({ url: this.url, method: "GET", - headers: { "Relay-Version": GIT_TAG }, + headers: { + "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, + ...getDeviceManagementHeaders(), + }, }) .then((response) => { if (response.status === 200) { diff --git a/src/customFetch.ts b/src/customFetch.ts index 2ca8d56..6da0810 100644 --- a/src/customFetch.ts +++ b/src/customFetch.ts @@ -1,5 +1,5 @@ "use strict"; -import { requestUrl } from "obsidian"; +import { apiVersion, requestUrl } from "obsidian"; import { Platform } from "obsidian"; import type { RequestUrlParam, RequestUrlResponse } from "obsidian"; import { curryLog } from "./debug"; @@ -7,6 +7,37 @@ import { flags } from "./flagManager"; declare const GIT_TAG: string; +// Device management configuration +let deviceManagementConfig: { + vaultId: string; + deviceId: string; +} | null = null; + +/** + * Set device management configuration for headers. + * Called from main.ts after DeviceManager is initialized. + */ +export function setDeviceManagementConfig(config: { + vaultId: string; + deviceId: string; +}): void { + deviceManagementConfig = config; +} + +/** + * Get device management headers if enabled. + * Returns empty object if not enabled or not configured. + */ +export function getDeviceManagementHeaders(): Record { + if (flags().enableDeviceManagement && deviceManagementConfig) { + return { + "Device-Id": deviceManagementConfig.deviceId, + "Vault-Id": deviceManagementConfig.vaultId, + }; + } + return {}; +} + if (globalThis.Response === undefined || globalThis.Headers === undefined) { // Fetch API is broken for some versions of Electron // https://github.com/electron/electron/pull/42419 @@ -47,9 +78,22 @@ export const customFetch = async ( const method = config?.method || "GET"; - const headers = Object.assign({}, config?.headers, { + const baseHeaders: Record = { "Relay-Version": GIT_TAG, - }) as Record; + "Obsidian-Version": apiVersion, + }; + + // Add device management headers if enabled + if (flags().enableDeviceManagement && deviceManagementConfig) { + baseHeaders["Device-Id"] = deviceManagementConfig.deviceId; + baseHeaders["Vault-Id"] = deviceManagementConfig.vaultId; + } + + const headers = Object.assign( + {}, + config?.headers, + baseHeaders, + ) as Record; // Prepare the request parameters const requestParams: RequestUrlParam = { diff --git a/src/flags.ts b/src/flags.ts index 2976c4f..9c47967 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -16,6 +16,7 @@ export interface FeatureFlags { enablePreviewViewHooks: boolean; enableMetadataViewHooks: boolean; enableKanbanView: boolean; + enableDeviceManagement: boolean; } export const FeatureFlagDefaults: FeatureFlags = { @@ -36,6 +37,7 @@ export const FeatureFlagDefaults: FeatureFlags = { enablePreviewViewHooks: true, enableMetadataViewHooks: true, enableKanbanView: true, + enableDeviceManagement: false, } as const; export function isKeyOfFeatureFlags(key: string): key is keyof FeatureFlags { diff --git a/src/main.ts b/src/main.ts index 386ac66..dc6776a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -63,6 +63,8 @@ import { ContentAddressedFileStore, isSyncFile } from "./SyncFile"; import { isDocument } from "./Document"; import { EndpointManager, type EndpointSettings } from "./EndpointManager"; import { SelfHostModal } from "./ui/SelfHostModal"; +import { DeviceManager } from "./DeviceManager"; +import { setDeviceManagementConfig } from "./customFetch"; interface DebugSettings { debugging: boolean; @@ -109,6 +111,7 @@ export default class Live extends Plugin { backgroundSync!: BackgroundSync; folderNavDecorations!: FolderNavigationDecorations; relayManager!: RelayManager; + deviceManager!: DeviceManager; settingsTab!: LiveSettingsTab; settings!: Settings; updateManager!: UpdateManager; @@ -529,6 +532,15 @@ export default class Live extends Plugin { endpointManager, ); this.relayManager = new RelayManager(this.loginManager); + this.deviceManager = new DeviceManager( + this.appId, + this.vault.getName(), + this.loginManager, + ); + setDeviceManagementConfig({ + vaultId: this.appId, + deviceId: this.deviceManager.getDeviceId(), + }); this.sharedFolders = new SharedFolders( this.relayManager, this.vault, @@ -771,6 +783,9 @@ export default class Live extends Plugin { this.sharedFolders.load(); this.relayManager?.login(); this._liveViews.refresh("login"); + withFlag(flag.enableDeviceManagement, () => { + this.deviceManager.register(); + }); } async openSettings(path: string = "/") { @@ -1167,6 +1182,9 @@ export default class Live extends Plugin { this.relayManager?.destroy(); this.relayManager = null as any; + this.deviceManager?.destroy(); + this.deviceManager = null as any; + this.tokenStore?.stop(); this.tokenStore?.clearState(); this.tokenStore?.destroy();