Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Sync Across Computers now syncs your content**: layouts and their charts (symbol, timeframe,
indicators, chart settings and themes), every drawing (on a chart, shared across a layout, or on a
symbol for all layouts), chart profiles and templates, drawing templates and last-used looks,
imported themes, watchlists, favourite indicators, timeframes and drawing tools, custom colours,
price alerts, Pine scripts, and the theme, replay, Trade Assistant, Risk Ticket and risk settings.
Panels, docks, which layout and chart are in front, and one-click trading consent stay with each
computer. A computer joining data that already syncs asks whether to merge its own layouts in or
use the synced ones; either way your synced settings are kept.
- **Saved accounts sync too.** In Sync Across Computers, **Passwords and identity** lets each
computer also sync saved passwords, API keys and webhook secrets (the newest saved password wins),
and **Use one CID on all my computers**, which asks which CID to keep if they differ.
- **Logging in to an account that is open on another computer asks first**, and **Move it here**
has that computer log out of it, so the two sessions do not keep disconnecting each other.

## [1.0.0] - 2026-11-04

### Added
Expand Down
53 changes: 53 additions & 0 deletions electron/account-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,56 @@ test('profiles saved before platforms existed load as MetaTrader 5 accounts', as
assert.equal(profile.platform, 'mt5');
assert.deepEqual(restarted.credentials(profile.id), { platform: 'mt5', login: 1234, password: 'fixture-secret', endpoint });
});

test('logging in again with the same saved password keeps when it was saved', async () => {
let stored: readonly { secretSavedAt?: number }[] = [];
const registry = new AccountRegistry(cipher, async (profiles) => { stored = profiles as typeof stored; });
await registry.remember(account, endpoint, 'first', undefined, true);
const savedAt = stored[0].secretSavedAt!;
assert.ok(savedAt > 0);
await new Promise((resolve) => setTimeout(resolve, 5));
await registry.remember(account, endpoint, 'first', undefined, true);
assert.equal(stored[0].secretSavedAt, savedAt);
await registry.remember(account, endpoint, 'changed', undefined, true);
assert.ok(stored[0].secretSavedAt! > savedAt);
});

test('synced profiles keep this computer\'s secret and latest use; ids come from the fields', async () => {
const registry = new AccountRegistry(cipher, async () => {});
const profile = await registry.remember(account, endpoint, 'local-secret', 'Mine', true);
const synced = registry.syncedProfiles();
assert.ok(!JSON.stringify(synced).includes('encrypted'));
assert.throws(() => registry.checkSyncedProfile('wss://other.example/terminal|1234', synced[profile.id]), /does not match/);
const other = { ...synced[profile.id], label: 'Renamed elsewhere', lastUsed: 1 };
await registry.applySyncedProfiles({ upserts: { [profile.id]: other }, deletes: [] });
assert.equal(registry.get(profile.id).label, 'Renamed elsewhere');
assert.equal(registry.get(profile.id).lastUsed, profile.lastUsed);
assert.equal(registry.password(profile.id), 'local-secret');
await registry.applySyncedProfiles({ upserts: {}, deletes: [profile.id] });
assert.equal(registry.list().length, 0);
});

test('the newest saved secret wins, and one that no longer decrypts is not read as removed', async () => {
const registry = new AccountRegistry(cipher, async () => {});
const profile = await registry.remember(account, endpoint, 'local', undefined, true);
const local = registry.syncedSecrets()[profile.id] as { secret: string; savedAt: number };
assert.equal(local.secret, 'local');
assert.throws(() => registry.checkSyncedSecret('wss://unknown.example/terminal|1', { secret: 'x', savedAt: 1 }), /does not have/);
await registry.applySyncedSecrets({ upserts: { [profile.id]: { secret: 'older', savedAt: local.savedAt - 1 } }, deletes: [] });
assert.equal(registry.password(profile.id), 'local');
await registry.applySyncedSecrets({ upserts: { [profile.id]: { secret: 'newer', savedAt: local.savedAt + 1 } }, deletes: [] });
assert.equal(registry.password(profile.id), 'newer');
const broken = new AccountRegistry({ ...cipher, decrypt: () => { throw new Error('keychain changed'); } }, async () => {});
await broken.remember(account, endpoint, 'x', undefined, true);
assert.deepEqual(Object.values(broken.syncedSecrets()), [{ isUnreadable: true }]);
await registry.applySyncedSecrets({ upserts: {}, deletes: [profile.id] });
assert.equal(registry.get(profile.id).isPasswordSaved, false);
});

test('a synced exchange secret must hold an API key and secret', async () => {
const registry = new AccountRegistry(cipher, async () => {});
const bybit = { ...account, login: 4242, server: 'Bybit Demo trading', company: 'Bybit' };
const profile = await registry.rememberExchange(bybit, { platform: 'bybit', environment: 'demo', apiKey: 'k', apiSecret: 's' });
assert.throws(() => registry.checkSyncedSecret(profile.id, { secret: 'plain password', savedAt: 1 }), /API key/);
registry.checkSyncedSecret(profile.id, { secret: JSON.stringify({ apiKey: 'k2', apiSecret: 's2' }), savedAt: 1 });
});
144 changes: 128 additions & 16 deletions electron/account-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,25 @@ import type { SessionCredentials } from './session-pool';

interface StoredProfile extends Omit<AccountProfile, 'hasPassword' | 'isPasswordSaved'> {
readonly encryptedPassword?: string;
// When the saved secret last changed, so the newest password wins between computers.
readonly secretSavedAt?: number;
}

// A profile as it syncs: everything but the secret, which syncs apart behind its own opt-in.
export type SyncedProfile = Omit<StoredProfile, 'id' | 'encryptedPassword' | 'secretSavedAt'>;

export interface SyncedSecret {
readonly secret: string;
readonly savedAt: number;
}

export interface ProfileChange<T> {
readonly upserts: Readonly<Record<string, T>>;
readonly deletes: readonly string[];
}

const MAX_SECRET_LENGTH = 8192;

export const exchangeAccountId = (platform: ExchangePlatform, environment: string, login: number) => `${platform}:${environment}|${login}`;

export const platformOf = (accountId: string): Platform => platforms.find((item) => item.key !== 'mt5' && accountId.startsWith(`${item.key}:`))?.key ?? 'mt5';
Expand All @@ -24,28 +41,123 @@ export class AccountRegistry {

load(value: unknown): void {
if (!Array.isArray(value) || value.length > 1000) throw new Error('Invalid saved accounts.');
const profiles = value.map((item): StoredProfile => {
if (!item || typeof item !== 'object') throw new Error('Invalid saved account.');
const { login, name, label, server, company, tradeMode, lastUsed, encryptedPassword } = item;
const platform = platforms.find((candidate) => candidate.key === (item.platform ?? 'mt5'))?.key;
if (!platform) throw new Error('Invalid saved account.');
const webEndpoint = isExchangePlatform(platform) ? this.environmentOf(platform, item.webEndpoint).restUrl : parseSettings(item).webEndpoint;
if (!Number.isSafeInteger(login) || login <= 0 || !['demo', 'real', 'contest'].includes(tradeMode) ||
![name, label, server, company].every((field) => typeof field === 'string' && field.length <= 512) ||
!Number.isFinite(lastUsed) || (encryptedPassword !== undefined && typeof encryptedPassword !== 'string')) {
throw new Error('Invalid saved account.');
const profiles = value.map((item) => this.parseProfile(item));
if (new Set(profiles.map((profile) => profile.id)).size !== profiles.length) throw new Error('Duplicate saved accounts.');
this.profiles = profiles;
}

// The id is derived from the fields, never read, so a profile cannot claim another's id.
private parseProfile(item: unknown): StoredProfile {
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error('Invalid saved account.');
const { login, name, label, server, company, tradeMode, lastUsed, encryptedPassword, secretSavedAt, ...rest } = item as Record<string, unknown>;
const platform = platforms.find((candidate) => candidate.key === (rest.platform ?? 'mt5'))?.key;
if (!platform) throw new Error('Invalid saved account.');
const webEndpoint = isExchangePlatform(platform) ? this.environmentOf(platform, rest.webEndpoint).restUrl : parseSettings(item as { webEndpoint?: string }).webEndpoint;
if (!Number.isSafeInteger(login) || (login as number) <= 0 || !['demo', 'real', 'contest'].includes(tradeMode as string) ||
![name, label, server, company].every((field) => typeof field === 'string' && field.length <= 512) ||
!Number.isFinite(lastUsed) || (encryptedPassword !== undefined && typeof encryptedPassword !== 'string') ||
(secretSavedAt !== undefined && !(Number.isFinite(secretSavedAt) && (secretSavedAt as number) >= 0))) {
throw new Error('Invalid saved account.');
}
const id = isExchangePlatform(platform) ? exchangeAccountId(platform, this.environmentOf(platform, webEndpoint).key, login as number) : this.key(webEndpoint, login as number);
return {
id, platform, login: login as number, name: name as string, label: label as string, server: server as string, company: company as string, webEndpoint,
tradeMode: tradeMode as AccountProfile['tradeMode'], lastUsed: lastUsed as number,
...(encryptedPassword ? { encryptedPassword: encryptedPassword as string, ...(secretSavedAt !== undefined ? { secretSavedAt: secretSavedAt as number } : {}) } : {}),
};
}

// Keeps the saved time of a secret that did not change, so logging in again does not make an
// old password look like the newest one.
private secretFields(existing: StoredProfile | undefined, secret: string): Pick<StoredProfile, 'encryptedPassword' | 'secretSavedAt'> {
const isSame = !!existing?.encryptedPassword && this.tryDecrypt(existing.encryptedPassword) === secret;
return { encryptedPassword: this.cipher.encrypt(secret), secretSavedAt: isSame ? existing!.secretSavedAt ?? 0 : Date.now() };
}

private tryDecrypt(value: string): string | null {
try { return this.cipher.decrypt(value); } catch { return null; }
}

syncedProfiles(): Record<string, SyncedProfile> {
return Object.fromEntries(this.profiles.map(({ id, encryptedPassword, secretSavedAt, ...profile }) => [id, profile]));
}

checkSyncedProfile(id: string, value: unknown): void {
if (this.parseProfile(value).id !== id) throw new Error('The account does not match its id.');
}

// Applies profiles from other computers. A profile keeps this computer's saved secret, and the
// most recent use of either side.
async applySyncedProfiles(change: ProfileChange<unknown>): Promise<void> {
const removed = new Set(change.deletes);
const profiles = this.profiles.filter((profile) => !removed.has(profile.id));
for (const [id, value] of Object.entries(change.upserts)) {
const { encryptedPassword, secretSavedAt, ...parsed } = this.parseProfile(value);
if (parsed.id !== id) throw new Error('The account does not match its id.');
const index = profiles.findIndex((profile) => profile.id === id);
const existing = profiles[index];
const profile: StoredProfile = existing
? { ...parsed, lastUsed: Math.max(parsed.lastUsed, existing.lastUsed), ...(existing.encryptedPassword ? { encryptedPassword: existing.encryptedPassword, secretSavedAt: existing.secretSavedAt } : {}) }
: parsed;
if (index < 0) profiles.push(profile);
else profiles[index] = profile;
}
await this.persist(profiles);
this.profiles = profiles;
for (const id of removed) this.passwords.delete(id);
}

// Saved secrets in plain text, for sync to encrypt under its own key. One that no longer
// decrypts here is listed as unreadable, so it is left alone rather than read as removed.
syncedSecrets(): Record<string, SyncedSecret | { readonly isUnreadable: true }> {
return Object.fromEntries(this.profiles.flatMap(({ id, encryptedPassword, secretSavedAt }) => {
if (!encryptedPassword) return [];
const secret = this.tryDecrypt(encryptedPassword);
return [[id, secret === null ? { isUnreadable: true } as const : { secret, savedAt: secretSavedAt ?? 0 }]];
}));
}

// A secret applies only to a profile already here (profiles sync first), and an exchange
// secret must hold an API key and secret.
checkSyncedSecret(id: string, value: unknown): void {
const { secret, savedAt } = (value ?? {}) as Partial<SyncedSecret>;
const profile = this.profiles.find((candidate) => candidate.id === id);
if (!profile) throw new Error('The secret belongs to an account this computer does not have yet.');
if (typeof secret !== 'string' || !secret || secret.length > MAX_SECRET_LENGTH || !Number.isFinite(savedAt) || savedAt! < 0) throw new Error('Invalid synced secret.');
if (!isExchangePlatform(profile.platform)) return;
const parsed = (() => { try { return JSON.parse(secret) as unknown; } catch { return null; } })() as { apiKey?: unknown; apiSecret?: unknown } | null;
if (typeof parsed?.apiKey !== 'string' || typeof parsed.apiSecret !== 'string' || !parsed.apiKey || !parsed.apiSecret) throw new Error('Invalid synced API key.');
}

// A secret saved here more recently than the synced one is kept; sync then sends it out, so
// every computer ends with the newest password.
async applySyncedSecrets(change: ProfileChange<unknown>): Promise<void> {
if (!this.cipher.canEncrypt()) throw new Error('Secure storage is unavailable, so synced passwords cannot be kept here.');
const removed = new Set(change.deletes);
const profiles = this.profiles.map((profile): StoredProfile => {
const value = change.upserts[profile.id];
if (value !== undefined) {
this.checkSyncedSecret(profile.id, value);
const { secret, savedAt } = value as SyncedSecret;
const isLocalNewer = !!profile.encryptedPassword && (profile.secretSavedAt ?? 0) > savedAt && this.tryDecrypt(profile.encryptedPassword) !== null;
return isLocalNewer ? profile : { ...profile, encryptedPassword: this.cipher.encrypt(secret), secretSavedAt: savedAt };
}
const id = isExchangePlatform(platform) ? exchangeAccountId(platform, this.environmentOf(platform, webEndpoint).key, login) : this.key(webEndpoint, login);
return { id, platform, login, name, label, server, company, webEndpoint, tradeMode, lastUsed, ...(encryptedPassword ? { encryptedPassword } : {}) };
if (!removed.has(profile.id) || !profile.encryptedPassword) return profile;
const { encryptedPassword, secretSavedAt, ...rest } = profile;
return rest;
});
if (new Set(profiles.map((profile) => profile.id)).size !== profiles.length) throw new Error('Duplicate saved accounts.');
await this.persist(profiles);
this.profiles = profiles;
for (const profile of profiles) {
if (change.upserts[profile.id] !== undefined && profile.encryptedPassword) this.passwords.set(profile.id, this.cipher.decrypt(profile.encryptedPassword));
if (removed.has(profile.id)) this.passwords.delete(profile.id);
}
}

canSavePasswords(): boolean { return this.cipher.canEncrypt(); }

list(): AccountProfile[] {
return this.profiles.map(({ encryptedPassword, ...profile }) => ({
return this.profiles.map(({ encryptedPassword, secretSavedAt, ...profile }) => ({
...profile, hasPassword: this.passwords.has(profile.id) || !!encryptedPassword && this.cipher.canEncrypt(),
isPasswordSaved: !!encryptedPassword,
})).sort((left, right) => right.lastUsed - left.lastUsed);
Expand Down Expand Up @@ -76,7 +188,7 @@ export class AccountRegistry {
id, platform: 'mt5', name: account.name, label: label?.trim() || existing?.label || `${account.company} ${account.login}`,
server: account.server, company: account.company, webEndpoint, tradeMode: account.tradeMode,
login: account.login, lastUsed: Date.now(),
...(isPasswordSaved ? { encryptedPassword: this.cipher.encrypt(password) } : {}),
...(isPasswordSaved ? this.secretFields(existing, password) : {}),
};
const profiles = [...this.profiles.filter((item) => item.id !== id), profile];
await this.persist(profiles);
Expand All @@ -98,7 +210,7 @@ export class AccountRegistry {
id, platform, name: account.name, label: label?.trim() || existing?.label || `${account.server} ${account.login}`,
server: account.server, company: account.company, webEndpoint: restUrl, tradeMode: account.tradeMode,
login: account.login, lastUsed: Date.now(),
...(isPasswordSaved ? { encryptedPassword: this.cipher.encrypt(secret) } : {}),
...(isPasswordSaved ? this.secretFields(existing, secret) : {}),
};
const profiles = [...this.profiles.filter((item) => item.id !== id), profile];
await this.persist(profiles);
Expand Down
4 changes: 3 additions & 1 deletion electron/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ export const secretCipher: PasswordCipher = {
decrypt: (value) => safeStorage.decryptString(Buffer.from(value, 'base64')),
};

export async function loadAccounts(): Promise<AccountRegistry> {
// `onSaved` runs after every write, so sync hears about saved accounts.
export async function loadAccounts(onSaved: () => void = () => undefined): Promise<AccountRegistry> {
const file = path.join(app.getPath('userData'), 'accounts.json');
const registry = new AccountRegistry(secretCipher, async (profiles) => {
await fs.mkdir(path.dirname(file), { recursive: true });
const temporary = `${file}.tmp`;
await fs.writeFile(temporary, JSON.stringify(profiles, null, 2), { mode: 0o600 });
await fs.rename(temporary, file);
onSaved();
});
try { registry.load(JSON.parse(await fs.readFile(file, 'utf8'))); }
catch (error) {
Expand Down
Loading
Loading