device-id.jsjavascript
/**
 * @file The machine's identity for the per-seat device cap: a random UUID, created once
 * and reused forever. Main-process only, like everything auth-adjacent.
 *
 * On macOS it lives in the Keychain (via /usr/bin/security), which survives app deletion
 * and app-data wipes — a reinstall keeps its slot. Anywhere the Keychain isn't available
 * (other platforms, locked keychain) it falls back to a plain JSON file in userData; the
 * ID is random and grants nothing by itself, so the file needs no encryption — the only
 * cost of the fallback is that wiping app data makes the machine look new (one
 * self-service deactivation fixes that).
 * @module device-id
 */
const { execFileSync } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

/** Keychain service name the device ID is stored under. @type {string} */
const SERVICE = 'com.ezlaybacks.device-id';

/**
 * Read the device ID from the macOS Keychain.
 * @returns {?string} The stored ID, or null when absent or the Keychain is unavailable.
 */
function keychainRead() {
  try {
    const out = execFileSync('/usr/bin/security',
      ['find-generic-password', '-s', SERVICE, '-w'],
      { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
    return out || null;
  } catch {
    return null;
  }
}

/**
 * Write the device ID to the macOS Keychain.
 * @param {string} id Device UUID to store.
 * @returns {boolean} True on success, false when the Keychain refused.
 */
function keychainWrite(id) {
  try {
    execFileSync('/usr/bin/security',
      ['add-generic-password', '-s', SERVICE, '-a', 'device', '-w', id, '-U'],
      { stdio: 'ignore' });
    return true;
  } catch {
    return false;
  }
}

/**
 * Get-or-create the device ID, stable across calls. Prefers the Keychain on macOS and
 * falls back to userData/device-id.json when the Keychain refuses, on other platforms,
 * or when EZL_PASSWORD_STORE=basic. That env var is the existing test hook (e2e +
 * headless CI): it opts safeStorage out of the OS keychain, and device identity follows
 * — the throwaway test profile gets a file-backed ID, so every e2e launch is a fresh
 * machine and tests never write to the developer's real Keychain. An unreadable or
 * first-run file mints a fresh UUID and writes it.
 * @param {string} userDataDir Electron userData directory (app.getPath('userData')).
 * @returns {string} The device UUID.
 */
function getDeviceId(userDataDir) {
  const useKeychain = process.platform === 'darwin' && process.env.EZL_PASSWORD_STORE !== 'basic';
  if (useKeychain) {
    const existing = keychainRead();
    if (existing) return existing;
    const id = crypto.randomUUID();
    if (keychainWrite(id)) return id;
    // keychain refused — fall through to the file
  }
  const file = path.join(userDataDir, 'device-id.json');
  try {
    const saved = JSON.parse(fs.readFileSync(file, 'utf8')).device_id;
    if (saved) return saved;
  } catch { /* first run or unreadable — mint below */ }
  const id = crypto.randomUUID();
  fs.mkdirSync(userDataDir, { recursive: true });
  fs.writeFileSync(file, JSON.stringify({ device_id: id }));
  return id;
}

module.exports = { getDeviceId };