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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -102,7 +103,7 @@ export const WorkspaceSection = (): React.JSX.Element => {

const handleAdd = async (): Promise<void> => {
const newProfile: SshConnectionProfile = {
id: crypto.randomUUID(),
id: generateUUID(),
name: formName.trim(),
host: formHost.trim(),
port: parseInt(formPort, 10) || 22,
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/store/slices/paneSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { MAX_PANES } from '@renderer/types/panes';
import { generateUUID } from '@renderer/utils/stringUtils';

import {
createEmptyPane,
Expand Down Expand Up @@ -140,7 +141,7 @@ export const createPaneSlice: StateCreator<AppState, [], [], PaneSlice> = (set,
};

// Create new pane with the tab
const newPaneId = crypto.randomUUID();
const newPaneId = generateUUID();
const newPane = {
...createEmptyPane(newPaneId),
tabs: [tab],
Expand Down Expand Up @@ -277,7 +278,7 @@ export const createPaneSlice: StateCreator<AppState, [], [], PaneSlice> = (set,
newSourceActiveTabId = newSourceTabs[oldIndex]?.id ?? newSourceTabs[oldIndex - 1]?.id ?? null;
}

const newPaneId = crypto.randomUUID();
const newPaneId = generateUUID();
const newPane = {
...createEmptyPane(newPaneId),
tabs: [tab],
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/store/slices/tabSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
findTabBySessionAndProject,
truncateLabel,
} from '@renderer/types/tabs';
import { generateUUID } from '@renderer/utils/stringUtils';

import {
findPane,
Expand Down Expand Up @@ -172,7 +173,7 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (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(),
};
Expand Down Expand Up @@ -365,7 +366,7 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
if (!focusedPane) return;

const newTab: Tab = {
id: crypto.randomUUID(),
id: generateUUID(),
type: 'dashboard',
label: 'Dashboard',
createdAt: Date.now(),
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/types/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -194,7 +196,7 @@ export function createErrorNavigationRequest(
highlightColor?: TriggerColor
): TabNavigationRequest {
return {
id: crypto.randomUUID(),
id: generateUUID(),
kind: 'error',
source,
highlight: highlightColor ?? 'red',
Expand All @@ -209,7 +211,7 @@ export function createSearchNavigationRequest(
payload: SearchNavigationPayload
): TabNavigationRequest {
return {
id: crypto.randomUUID(),
id: generateUUID(),
kind: 'search',
source: 'commandPalette',
highlight: 'yellow',
Expand Down
21 changes: 20 additions & 1 deletion src/renderer/utils/stringUtils.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand Down
63 changes: 63 additions & 0 deletions test/renderer/utils/stringUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading