Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/DeviceManager.ts
Original file line number Diff line number Diff line change
@@ -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<DeviceManager> {
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<void> {
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<void> {
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<void> {
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();
}
}
11 changes: 9 additions & 2 deletions src/LoginManager.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -192,6 +192,7 @@ export class LoginManager extends Observable<LoginManager> {
options.fetch = customFetch;
options.headers = Object.assign({}, options.headers, {
"Relay-Version": GIT_TAG,
"Obsidian-Version": apiVersion,
});
return { url, options };
};
Expand Down Expand Up @@ -283,6 +284,8 @@ export class LoginManager extends Observable<LoginManager> {
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`,
Expand All @@ -295,6 +298,8 @@ export class LoginManager extends Observable<LoginManager> {
const headers = {
Authorization: `Bearer ${this.pb.authStore.token}`,
"Relay-Version": GIT_TAG,
"Obsidian-Version": apiVersion,
...getDeviceManagementHeaders(),
};
requestUrl({
url: `${this.endpointManager.getApiUrl()}/flags`,
Expand All @@ -315,6 +320,7 @@ export class LoginManager extends Observable<LoginManager> {
whoami() {
const headers = {
Authorization: `Bearer ${this.pb.authStore.token}`,
...getDeviceManagementHeaders(),
};
requestUrl({
url: `${this.endpointManager.getApiUrl()}/whoami`,
Expand Down Expand Up @@ -362,6 +368,7 @@ export class LoginManager extends Observable<LoginManager> {
options.fetch = customFetch;
options.headers = Object.assign({}, options.headers, {
"Relay-Version": GIT_TAG,
"Obsidian-Version": apiVersion,
});
return { url, options };
};
Expand Down
9 changes: 7 additions & 2 deletions src/NetworkStatus.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
50 changes: 47 additions & 3 deletions src/customFetch.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
"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";
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<string, string> {
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
Expand Down Expand Up @@ -47,9 +78,22 @@ export const customFetch = async (

const method = config?.method || "GET";

const headers = Object.assign({}, config?.headers, {
const baseHeaders: Record<string, string> = {
"Relay-Version": GIT_TAG,
}) as Record<string, string>;
"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<string, string>;

// Prepare the request parameters
const requestParams: RequestUrlParam = {
Expand Down
2 changes: 2 additions & 0 deletions src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface FeatureFlags {
enablePreviewViewHooks: boolean;
enableMetadataViewHooks: boolean;
enableKanbanView: boolean;
enableDeviceManagement: boolean;
}

export const FeatureFlagDefaults: FeatureFlags = {
Expand All @@ -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 {
Expand Down
Loading