diff --git a/src/renderer/components/settings/NotificationTriggerSettings/utils/trigger.ts b/src/renderer/components/settings/NotificationTriggerSettings/utils/trigger.ts index e0d42b77..d18b457d 100644 --- a/src/renderer/components/settings/NotificationTriggerSettings/utils/trigger.ts +++ b/src/renderer/components/settings/NotificationTriggerSettings/utils/trigger.ts @@ -2,13 +2,15 @@ * Utility functions for notification triggers. */ +import { generateUUID } from '@renderer/utils/stringUtils'; + import type { NotificationTrigger, TriggerContentType, TriggerMode } from '@renderer/types/data'; /** * Generates a UUID v4 for new triggers. */ export function generateId(): string { - return crypto.randomUUID(); + return generateUUID(); } /** diff --git a/src/renderer/components/settings/sections/WorkspaceSection.tsx b/src/renderer/components/settings/sections/WorkspaceSection.tsx index aab157af..2093f315 100644 --- a/src/renderer/components/settings/sections/WorkspaceSection.tsx +++ b/src/renderer/components/settings/sections/WorkspaceSection.tsx @@ -15,6 +15,7 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { useStore } from '@renderer/store'; +import { generateUUID } from '@renderer/utils/stringUtils'; import { Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react'; import { SettingsSectionHeader } from '../components/SettingsSectionHeader'; @@ -102,7 +103,7 @@ export const WorkspaceSection = (): React.JSX.Element => { const handleAdd = async (): Promise => { const newProfile: SshConnectionProfile = { - id: crypto.randomUUID(), + id: generateUUID(), name: formName.trim(), host: formHost.trim(), port: parseInt(formPort, 10) || 22, diff --git a/src/renderer/store/slices/paneSlice.ts b/src/renderer/store/slices/paneSlice.ts index 85d6628e..39505fc8 100644 --- a/src/renderer/store/slices/paneSlice.ts +++ b/src/renderer/store/slices/paneSlice.ts @@ -4,6 +4,7 @@ */ import { MAX_PANES } from '@renderer/types/panes'; +import { generateUUID } from '@renderer/utils/stringUtils'; import { createEmptyPane, @@ -140,7 +141,7 @@ export const createPaneSlice: StateCreator = (set, }; // Create new pane with the tab - const newPaneId = crypto.randomUUID(); + const newPaneId = generateUUID(); const newPane = { ...createEmptyPane(newPaneId), tabs: [tab], @@ -277,7 +278,7 @@ export const createPaneSlice: StateCreator = (set, newSourceActiveTabId = newSourceTabs[oldIndex]?.id ?? newSourceTabs[oldIndex - 1]?.id ?? null; } - const newPaneId = crypto.randomUUID(); + const newPaneId = generateUUID(); const newPane = { ...createEmptyPane(newPaneId), tabs: [tab], diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index 7cb9f4b9..bf7971b8 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -12,6 +12,7 @@ import { findTabBySessionAndProject, truncateLabel, } from '@renderer/types/tabs'; +import { generateUUID } from '@renderer/utils/stringUtils'; import { findPane, @@ -172,7 +173,7 @@ export const createTabSlice: StateCreator = (set, ge // Create new tab with generated id and timestamp const newTab: Tab = { ...tab, - id: crypto.randomUUID(), + id: generateUUID(), label: truncateLabel(tab.label), createdAt: Date.now(), }; @@ -365,7 +366,7 @@ export const createTabSlice: StateCreator = (set, ge if (!focusedPane) return; const newTab: Tab = { - id: crypto.randomUUID(), + id: generateUUID(), type: 'dashboard', label: 'Dashboard', createdAt: Date.now(), diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index 4e60603e..5a54682c 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -3,6 +3,8 @@ * Based on specs/001-tabbed-layout-dashboard/contracts/tab-state.ts */ +import { generateUUID } from '@renderer/utils/stringUtils'; + import type { Session } from './data'; import type { TriggerColor } from '@shared/constants/triggerColors'; @@ -194,7 +196,7 @@ export function createErrorNavigationRequest( highlightColor?: TriggerColor ): TabNavigationRequest { return { - id: crypto.randomUUID(), + id: generateUUID(), kind: 'error', source, highlight: highlightColor ?? 'red', @@ -209,7 +211,7 @@ export function createSearchNavigationRequest( payload: SearchNavigationPayload ): TabNavigationRequest { return { - id: crypto.randomUUID(), + id: generateUUID(), kind: 'search', source: 'commandPalette', highlight: 'yellow', diff --git a/src/renderer/utils/stringUtils.ts b/src/renderer/utils/stringUtils.ts index 71bae686..675ddb17 100644 --- a/src/renderer/utils/stringUtils.ts +++ b/src/renderer/utils/stringUtils.ts @@ -1,7 +1,26 @@ /** - * String utilities for display formatting. + * String utilities for display formatting and ID generation. */ +/** + * Generates a UUID v4. + * + * Prefers `crypto.randomUUID()` (available in secure contexts, i.e. in HTTPS or localhost). + * Falls back to `crypto.getRandomValues()` for non-secure contexts. + */ +export function generateUUID(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // UUID v4 via getRandomValues - available in non-secure contexts + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant bits + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`; +} + const isMacPlatform = typeof window !== 'undefined' && window.navigator.userAgent.includes('Macintosh'); diff --git a/test/renderer/utils/stringUtils.test.ts b/test/renderer/utils/stringUtils.test.ts new file mode 100644 index 00000000..567e7cab --- /dev/null +++ b/test/renderer/utils/stringUtils.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { generateUUID } from '../../../src/renderer/utils/stringUtils'; + +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('generateUUID', () => { + it('delegates to crypto.randomUUID when available', () => { + const KNOWN_UUID = '12345678-1234-4234-8234-123456789abc'; + const spy = vi.spyOn(crypto, 'randomUUID').mockReturnValue(KNOWN_UUID); + const result = generateUUID(); + expect(spy).toHaveBeenCalled(); + expect(result).toBe(KNOWN_UUID); + expect(result).toMatch(UUID_V4_PATTERN); + spy.mockRestore(); + }); + + describe('getRandomValues fallback (non-secure context)', () => { + const originalRandomUUID = crypto.randomUUID; + + // Deterministic 16-byte input: all 0xff so we can predict the masked output + const FIXED_BYTES = new Uint8Array(16).fill(0xff); + + beforeEach(() => { + // Simulate non-secure context: randomUUID is unavailable + // @ts-expect-error — intentionally removing a required property for test + crypto.randomUUID = undefined; + + vi.spyOn(crypto, 'getRandomValues').mockImplementation((array) => { + (array as Uint8Array).set(FIXED_BYTES); + return array as Uint8Array; + }); + }); + + afterEach(() => { + crypto.randomUUID = originalRandomUUID; + vi.restoreAllMocks(); + }); + + it('returns a valid UUID v4 string', () => { + expect(generateUUID()).toMatch(UUID_V4_PATTERN); + }); + + it('sets version nibble (13th hex char) to "4"', () => { + const uuid = generateUUID(); + // xxxxxxxx-xxxx-[4]xxx-xxxx-xxxxxxxxxxxx + expect(uuid[14]).toBe('4'); + }); + + it('sets variant bits (17th hex char) to 8, 9, a, or b', () => { + const uuid = generateUUID(); + // xxxxxxxx-xxxx-xxxx-[v]xxx-xxxxxxxxxxxx + expect(['8', '9', 'a', 'b']).toContain(uuid[19]); + }); + + it('applies version and variant masks correctly to 0xff input', () => { + const uuid = generateUUID(); + // byte[6] = 0xff & 0x0f | 0x40 = 0x4f → '4f' + // byte[8] = 0xff & 0x3f | 0x80 = 0xbf → 'bf' + expect(uuid).toBe('ffffffff-ffff-4fff-bfff-ffffffffffff'); + }); + }); +});