Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfaee48553 | ||
|
|
1f05fe3efa | ||
|
|
e9c3b82c16 | ||
|
|
83fce70b20 | ||
|
|
d36c8bcbea | ||
|
|
5346752994 | ||
|
|
d267c4b6fc | ||
|
|
1a1da02e92 | ||
|
|
1adcffa7a8 | ||
|
|
7a2bedc4f4 | ||
|
|
5e753334ed | ||
|
|
a488bc466b | ||
|
|
2748cd5363 | ||
|
|
033165561d | ||
|
|
8e514f1008 | ||
|
|
0acd39603f | ||
|
|
4bdb0bbbf7 | ||
|
|
6b2c58f8f0 |
@@ -390,6 +390,8 @@ const en: Messages = {
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
|
||||
@@ -257,6 +257,8 @@ const zhCN: Messages = {
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
ConnectionLog,
|
||||
@@ -29,6 +29,14 @@ import {
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
} from "../../infrastructure/persistence/secureFieldAdapter";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
@@ -99,20 +107,47 @@ export const useVaultState = () => {
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
// persists if its version still matches the latest.
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
// event bumps the counter; the async decrypt callback only applies state if
|
||||
// its sequence still matches, preventing stale decrypts from overwriting
|
||||
// newer data when multiple events arrive in quick succession.
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
setHosts(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(cleaned).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
encryptKeys(data).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
encryptIdentities(data).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
@@ -271,7 +306,11 @@ export const useVaultState = () => {
|
||||
// Add to hosts using functional update
|
||||
setHosts((prevHosts) => {
|
||||
const updated = [...prevHosts, sanitizeHost(newHost)];
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(updated).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -279,82 +318,120 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
const init = async () => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
const sanitized = savedHosts.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
setKeys(migratedKeys);
|
||||
// Persist migrated keys
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedIdentities) setIdentities(savedIdentities);
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
};
|
||||
|
||||
init();
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -367,7 +444,17 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_HOSTS) {
|
||||
const next = safeParse<Host[]>(event.newValue) ?? [];
|
||||
setHosts(next.map(sanitizeHost));
|
||||
// Bump write version to invalidate any in-flight encrypt from this
|
||||
// window — the cross-window data is newer and must not be overwritten.
|
||||
++hostsWriteVersion.current;
|
||||
const seq = ++hostsReadSeq.current;
|
||||
const writeAtStart = hostsWriteVersion.current;
|
||||
decryptHosts(next).then((dec) => {
|
||||
// Discard if a newer storage event arrived OR a local write occurred
|
||||
// during the decrypt (writeVersion would have advanced).
|
||||
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
|
||||
setHosts(dec.map(sanitizeHost));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -380,13 +467,25 @@ export const useVaultState = () => {
|
||||
if (!record || isLegacyUnsupportedKey(record)) continue;
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
setKeys(migratedKeys);
|
||||
++keysWriteVersion.current;
|
||||
const seq = ++keysReadSeq.current;
|
||||
const writeAtStart = keysWriteVersion.current;
|
||||
decryptKeys(migratedKeys).then((dec) => {
|
||||
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
|
||||
setKeys(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_IDENTITIES) {
|
||||
const next = safeParse<Identity[]>(event.newValue) ?? [];
|
||||
setIdentities(next);
|
||||
++identitiesWriteVersion.current;
|
||||
const seq = ++identitiesReadSeq.current;
|
||||
const writeAtStart = identitiesWriteVersion.current;
|
||||
decryptIdentities(next).then((dec) => {
|
||||
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
|
||||
setIdentities(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,7 +541,11 @@ export const useVaultState = () => {
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, distro: normalized } : h,
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(next).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -543,8 +543,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className="h-8 px-3 flex items-center gap-2 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12 }}
|
||||
className="h-8 flex items-center gap-2 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
|
||||
>
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
|
||||
@@ -654,8 +654,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</div>
|
||||
{/* Custom window controls for Windows/Linux */}
|
||||
{!isMacClient && <WindowControls />}
|
||||
{/* Small drag shim to the right edge */}
|
||||
<div className="w-2 h-8 app-drag flex-shrink-0" />
|
||||
{/* Small drag shim to the right edge (macOS only – on Windows the close button should touch the edge) */}
|
||||
{isMacClient && <div className="w-2 h-8 app-drag flex-shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2003,11 +2003,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -2139,11 +2138,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
85
electron/bridges/credentialBridge.cjs
Normal file
85
electron/bridges/credentialBridge.cjs
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Credential Bridge - Field-level encryption for sensitive data at rest
|
||||
*
|
||||
* Uses Electron's safeStorage API to encrypt individual sensitive fields
|
||||
* (passwords, tokens, private keys) before they are persisted to localStorage.
|
||||
*
|
||||
* Sentinel prefix "enc:v1:" on encrypted values enables:
|
||||
* - Detection of already-encrypted vs plaintext (migration)
|
||||
* - No double-encryption
|
||||
* - Future re-keying with enc:v2: etc.
|
||||
*
|
||||
* When safeStorage is unavailable (e.g. Linux without libsecret), all values
|
||||
* pass through unmodified so the app still works.
|
||||
*/
|
||||
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
|
||||
let safeStorage = null;
|
||||
|
||||
/**
|
||||
* Register IPC handlers for credential encryption/decryption
|
||||
* @param {Electron.IpcMain} ipcMain
|
||||
* @param {typeof Electron} electronModule
|
||||
*/
|
||||
function registerHandlers(ipcMain, electronModule) {
|
||||
safeStorage = electronModule?.safeStorage ?? null;
|
||||
|
||||
ipcMain.handle("netcatty:credentials:available", () => {
|
||||
return Boolean(safeStorage?.isEncryptionAvailable?.());
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:credentials:encrypt", (_event, plaintext) => {
|
||||
if (typeof plaintext !== "string" || plaintext.length === 0) {
|
||||
return plaintext ?? "";
|
||||
}
|
||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||
return plaintext;
|
||||
}
|
||||
// If value looks like it might already be encrypted, verify by attempting
|
||||
// to decode and decrypt. If it succeeds the value is genuinely encrypted
|
||||
// and we return it as-is; if it fails, the prefix was a coincidence and
|
||||
// we proceed to encrypt the raw plaintext.
|
||||
if (plaintext.startsWith(ENC_PREFIX)) {
|
||||
try {
|
||||
const base64 = plaintext.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
safeStorage.decryptString(buf); // throws on invalid ciphertext
|
||||
return plaintext; // verified — already encrypted
|
||||
} catch {
|
||||
// Not valid ciphertext — fall through to encrypt
|
||||
}
|
||||
}
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(plaintext);
|
||||
return ENC_PREFIX + encrypted.toString("base64");
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] encrypt failed, returning plaintext:", err?.message || err);
|
||||
return plaintext;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:credentials:decrypt", (_event, value) => {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return value ?? "";
|
||||
}
|
||||
// Not encrypted — pass through (supports migration from plaintext)
|
||||
if (!value.startsWith(ENC_PREFIX)) {
|
||||
return value;
|
||||
}
|
||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||
// Cannot decrypt without safeStorage; return raw value
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const base64 = value.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
return safeStorage.decryptString(buf);
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] decrypt failed:", err?.message || err);
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerHandlers };
|
||||
@@ -81,6 +81,7 @@ const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -402,6 +403,7 @@ const registerBridges = (win) => {
|
||||
sessionLogsBridge.registerHandlers(ipcMain);
|
||||
compressUploadBridge.registerHandlers(ipcMain);
|
||||
globalShortcutBridge.registerHandlers(ipcMain);
|
||||
credentialBridge.registerHandlers(ipcMain, electronModule);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
|
||||
@@ -845,6 +845,11 @@ const api = {
|
||||
readClipboardText: async () => {
|
||||
return ipcRenderer.invoke("netcatty:clipboard:readText");
|
||||
},
|
||||
|
||||
// Credential encryption (field-level safeStorage)
|
||||
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
|
||||
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
|
||||
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
|
||||
};
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
|
||||
5
global.d.ts
vendored
5
global.d.ts
vendored
@@ -582,6 +582,11 @@ declare global {
|
||||
getPathForFile?(file: File): string | undefined;
|
||||
readClipboardText?(): Promise<string>;
|
||||
|
||||
// Credential encryption (field-level safeStorage for sensitive data at rest)
|
||||
credentialsAvailable?(): Promise<boolean>;
|
||||
credentialsEncrypt?(plaintext: string): Promise<string>;
|
||||
credentialsDecrypt?(value: string): Promise<string>;
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey?(hotkey: string): Promise<{ success: boolean; enabled?: boolean; error?: string; accelerator?: string }>;
|
||||
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
|
||||
|
||||
184
infrastructure/persistence/secureFieldAdapter.ts
Normal file
184
infrastructure/persistence/secureFieldAdapter.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Secure Field Adapter — Renderer-side helpers for field-level encryption
|
||||
*
|
||||
* Encrypts / decrypts individual sensitive fields within domain models before
|
||||
* they are written to (or after they are read from) localStorage.
|
||||
*
|
||||
* The heavy lifting is done by Electron's safeStorage via the credential
|
||||
* bridge IPC. When the bridge is unavailable (web fallback, tests) every
|
||||
* function degrades to a no-op — values pass through unmodified.
|
||||
*/
|
||||
|
||||
import type { Host, Identity, SSHKey } from "../../domain/models";
|
||||
import type { ProviderConnection, S3Config, WebDAVConfig } from "../../domain/sync";
|
||||
import { netcattyBridge } from "../services/netcattyBridge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const bridge = () => netcattyBridge.get();
|
||||
|
||||
export async function encryptField(value: string | undefined): Promise<string | undefined> {
|
||||
if (!value) return value;
|
||||
const b = bridge();
|
||||
if (!b?.credentialsEncrypt) return value;
|
||||
return b.credentialsEncrypt(value);
|
||||
}
|
||||
|
||||
export async function decryptField(value: string | undefined): Promise<string | undefined> {
|
||||
if (!value) return value;
|
||||
const b = bridge();
|
||||
if (!b?.credentialsDecrypt) return value;
|
||||
return b.credentialsDecrypt(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptHostSecrets(host: Host): Promise<Host> {
|
||||
const out = { ...host };
|
||||
out.password = await encryptField(out.password);
|
||||
out.telnetPassword = await encryptField(out.telnetPassword);
|
||||
if (out.proxyConfig?.password) {
|
||||
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptHostSecrets(host: Host): Promise<Host> {
|
||||
const out = { ...host };
|
||||
out.password = await decryptField(out.password);
|
||||
out.telnetPassword = await decryptField(out.telnetPassword);
|
||||
if (out.proxyConfig?.password) {
|
||||
out.proxyConfig = { ...out.proxyConfig, password: await decryptField(out.proxyConfig.password) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSHKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptKeySecrets(key: SSHKey): Promise<SSHKey> {
|
||||
const out = { ...key };
|
||||
out.passphrase = await encryptField(out.passphrase);
|
||||
out.privateKey = (await encryptField(out.privateKey)) ?? "";
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptKeySecrets(key: SSHKey): Promise<SSHKey> {
|
||||
const out = { ...key };
|
||||
out.passphrase = await decryptField(out.passphrase);
|
||||
out.privateKey = (await decryptField(out.privateKey)) ?? "";
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptIdentitySecrets(identity: Identity): Promise<Identity> {
|
||||
const out = { ...identity };
|
||||
out.password = await encryptField(out.password);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptIdentitySecrets(identity: Identity): Promise<Identity> {
|
||||
const out = { ...identity };
|
||||
out.password = await decryptField(out.password);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Connection (Cloud Sync)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
|
||||
const out = { ...conn };
|
||||
|
||||
if (out.tokens) {
|
||||
const t = { ...out.tokens };
|
||||
t.accessToken = (await encryptField(t.accessToken)) ?? "";
|
||||
t.refreshToken = await encryptField(t.refreshToken);
|
||||
out.tokens = t;
|
||||
}
|
||||
|
||||
if (out.config) {
|
||||
// WebDAV — use authType (required field unique to WebDAVConfig) as discriminator
|
||||
// so that token-auth configs (which may lack a password key after JSON round-trip)
|
||||
// still get their token field encrypted.
|
||||
if ("authType" in out.config) {
|
||||
const c = { ...out.config } as WebDAVConfig;
|
||||
c.password = await encryptField(c.password);
|
||||
c.token = await encryptField(c.token);
|
||||
out.config = c;
|
||||
}
|
||||
// S3
|
||||
if ("secretAccessKey" in out.config) {
|
||||
const c = { ...out.config } as S3Config;
|
||||
c.secretAccessKey = (await encryptField(c.secretAccessKey)) ?? "";
|
||||
c.sessionToken = await encryptField(c.sessionToken);
|
||||
out.config = c;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
|
||||
const out = { ...conn };
|
||||
|
||||
if (out.tokens) {
|
||||
const t = { ...out.tokens };
|
||||
t.accessToken = (await decryptField(t.accessToken)) ?? "";
|
||||
t.refreshToken = await decryptField(t.refreshToken);
|
||||
out.tokens = t;
|
||||
}
|
||||
|
||||
if (out.config) {
|
||||
if ("authType" in out.config) {
|
||||
const c = { ...out.config } as WebDAVConfig;
|
||||
c.password = await decryptField(c.password);
|
||||
c.token = await decryptField(c.token);
|
||||
out.config = c;
|
||||
}
|
||||
if ("secretAccessKey" in out.config) {
|
||||
const c = { ...out.config } as S3Config;
|
||||
c.secretAccessKey = (await decryptField(c.secretAccessKey)) ?? "";
|
||||
c.sessionToken = await decryptField(c.sessionToken);
|
||||
out.config = c;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function encryptHosts(hosts: Host[]): Promise<Host[]> {
|
||||
return Promise.all(hosts.map(encryptHostSecrets));
|
||||
}
|
||||
|
||||
export function decryptHosts(hosts: Host[]): Promise<Host[]> {
|
||||
return Promise.all(hosts.map(decryptHostSecrets));
|
||||
}
|
||||
|
||||
export function encryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
|
||||
return Promise.all(keys.map(encryptKeySecrets));
|
||||
}
|
||||
|
||||
export function decryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
|
||||
return Promise.all(keys.map(decryptKeySecrets));
|
||||
}
|
||||
|
||||
export function encryptIdentities(identities: Identity[]): Promise<Identity[]> {
|
||||
return Promise.all(identities.map(encryptIdentitySecrets));
|
||||
}
|
||||
|
||||
export function decryptIdentities(identities: Identity[]): Promise<Identity[]> {
|
||||
return Promise.all(identities.map(decryptIdentitySecrets));
|
||||
}
|
||||
@@ -38,6 +38,10 @@ import { createAdapter, type CloudAdapter } from './adapters';
|
||||
import type { GitHubAdapter } from './adapters/GitHubAdapter';
|
||||
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
|
||||
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
|
||||
import {
|
||||
decryptProviderSecrets,
|
||||
encryptProviderSecrets,
|
||||
} from '../persistence/secureFieldAdapter';
|
||||
|
||||
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
|
||||
|
||||
@@ -79,11 +83,25 @@ export class CloudSyncManager {
|
||||
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private masterPassword: string | null = null; // In memory only!
|
||||
private hasStorageListener = false;
|
||||
// Per-provider sequence counters for async decrypt callbacks (startup,
|
||||
// cross-window storage events). Bumped by any state mutation so stale
|
||||
// decrypt results are discarded.
|
||||
private providerDecryptSeq: Record<CloudProvider, number> = {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
};
|
||||
// Per-provider write sequence counters for saveProviderConnection.
|
||||
// Only bumped when a new save is initiated, so status-only updates
|
||||
// (which don't persist) cannot discard an in-flight encrypted write.
|
||||
private providerWriteSeq: Record<CloudProvider, number> = {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.state = this.loadInitialState();
|
||||
this.stateSnapshot = { ...this.state };
|
||||
this.setupCrossWindowSync();
|
||||
// Decrypt provider secrets asynchronously after initial load
|
||||
this.initProviderDecryption();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -167,11 +185,41 @@ export class CloudSyncManager {
|
||||
} as ProviderConnection;
|
||||
}
|
||||
|
||||
private saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): void {
|
||||
/**
|
||||
* Asynchronously decrypt provider connection secrets after initial load.
|
||||
* Runs once at construction; decrypted tokens replace the encrypted ones
|
||||
* in-memory so adapters can use them.
|
||||
*/
|
||||
private async initProviderDecryption(): Promise<void> {
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const p of providers) {
|
||||
try {
|
||||
const conn = this.state.providers[p];
|
||||
if (conn.tokens || conn.config) {
|
||||
const seq = ++this.providerDecryptSeq[p];
|
||||
const decrypted = await decryptProviderSecrets(conn);
|
||||
// Only apply if no newer update has occurred during the async gap
|
||||
if (seq === this.providerDecryptSeq[p]) {
|
||||
this.state.providers[p] = decrypted;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Decryption failure is non-fatal; the adapter will fail on use
|
||||
}
|
||||
}
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
private async saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): Promise<void> {
|
||||
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
|
||||
// Don't persist sensitive tokens directly - use safeStorage in production
|
||||
const { tokens, ...safeData } = connection;
|
||||
this.saveToStorage(key, { ...safeData, tokens }); // In production, encrypt tokens/config
|
||||
// Use write-specific counter so status-only updates cannot discard
|
||||
// an in-flight encrypted write that must be persisted.
|
||||
const seq = ++this.providerWriteSeq[provider];
|
||||
const encrypted = await encryptProviderSecrets(connection);
|
||||
// Only persist if no newer save has started during the async gap
|
||||
if (seq === this.providerWriteSeq[provider]) {
|
||||
this.saveToStorage(key, encrypted);
|
||||
}
|
||||
}
|
||||
|
||||
private loadFromStorage<T>(key: string): T | null {
|
||||
@@ -292,48 +340,61 @@ export class CloudSyncManager {
|
||||
};
|
||||
const provider = providerByKey[key];
|
||||
if (provider) {
|
||||
const prev = this.state.providers[provider];
|
||||
const next = this.loadProviderConnection(provider);
|
||||
const rawNext = this.loadProviderConnection(provider);
|
||||
const seq = ++this.providerDecryptSeq[provider];
|
||||
// Also bump write seq so any in-flight save from this window for the
|
||||
// same provider is discarded — the cross-window data is newer.
|
||||
++this.providerWriteSeq[provider];
|
||||
|
||||
const preserveTransientStatus =
|
||||
prev.status === 'connecting' || prev.status === 'syncing';
|
||||
// Decrypt secrets asynchronously, then update state.
|
||||
// Use sequence counter to discard stale results when multiple events
|
||||
// for the same provider arrive in quick succession.
|
||||
decryptProviderSecrets(rawNext).then((next) => {
|
||||
if (seq !== this.providerDecryptSeq[provider]) return; // stale — discard
|
||||
|
||||
this.state.providers[provider] = {
|
||||
...next,
|
||||
status: preserveTransientStatus ? prev.status : next.status,
|
||||
error: preserveTransientStatus ? prev.error : next.error,
|
||||
};
|
||||
const prev = this.state.providers[provider];
|
||||
const preserveTransientStatus =
|
||||
prev.status === 'connecting' || prev.status === 'syncing';
|
||||
|
||||
const nextTokens = next.tokens;
|
||||
const nextConfig = next.config;
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!nextTokens && !nextConfig) {
|
||||
if (adapter) {
|
||||
this.state.providers[provider] = {
|
||||
...next,
|
||||
status: preserveTransientStatus ? prev.status : next.status,
|
||||
error: preserveTransientStatus ? prev.error : next.error,
|
||||
};
|
||||
|
||||
const nextTokens = next.tokens;
|
||||
const nextConfig = next.config;
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!nextTokens && !nextConfig) {
|
||||
if (adapter) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
this.notifyStateChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenChanged =
|
||||
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
|
||||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
|
||||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
|
||||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
|
||||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
|
||||
|
||||
const configChanged =
|
||||
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
|
||||
|
||||
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
|
||||
|
||||
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
this.notifyStateChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenChanged =
|
||||
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
|
||||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
|
||||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
|
||||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
|
||||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
|
||||
|
||||
const configChanged =
|
||||
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
|
||||
|
||||
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
|
||||
|
||||
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
this.notifyStateChange();
|
||||
}).catch(() => {
|
||||
// Decryption failure in cross-window handler is non-fatal
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -637,6 +698,7 @@ export class CloudSyncManager {
|
||||
try {
|
||||
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
|
||||
|
||||
++this.providerDecryptSeq.github;
|
||||
this.state.providers.github = {
|
||||
...this.state.providers.github,
|
||||
status: 'connected',
|
||||
@@ -650,7 +712,7 @@ export class CloudSyncManager {
|
||||
this.state.providers.github.resourceId = resourceId;
|
||||
}
|
||||
|
||||
this.saveProviderConnection('github', this.state.providers.github);
|
||||
await this.saveProviderConnection('github', this.state.providers.github);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: 'github',
|
||||
@@ -689,6 +751,7 @@ export class CloudSyncManager {
|
||||
account = odAdapter.accountInfo;
|
||||
}
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
status: 'connected',
|
||||
@@ -702,7 +765,7 @@ export class CloudSyncManager {
|
||||
this.state.providers[provider].resourceId = resourceId;
|
||||
}
|
||||
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -729,6 +792,7 @@ export class CloudSyncManager {
|
||||
const resourceId = await adapter.initializeSync();
|
||||
const account = adapter.accountInfo || this.buildAccountFromConfig(provider, config);
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
provider,
|
||||
status: 'connected',
|
||||
@@ -737,7 +801,7 @@ export class CloudSyncManager {
|
||||
resourceId: resourceId || undefined,
|
||||
};
|
||||
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -759,12 +823,13 @@ export class CloudSyncManager {
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
provider,
|
||||
status: 'disconnected',
|
||||
};
|
||||
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
||||
}
|
||||
|
||||
@@ -773,6 +838,8 @@ export class CloudSyncManager {
|
||||
status: ProviderConnection['status'],
|
||||
error?: string
|
||||
): void {
|
||||
// Bump sequence to invalidate any in-flight async decrypt for this provider
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
status,
|
||||
@@ -842,11 +909,14 @@ export class CloudSyncManager {
|
||||
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
|
||||
this.state.remoteVersion = syncedFile.meta.version;
|
||||
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
|
||||
// Invalidate any pending provider decrypt so it cannot overwrite
|
||||
// the lastSync/lastSyncVersion we are about to set.
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider].lastSync = Date.now();
|
||||
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
|
||||
|
||||
this.saveSyncConfig();
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange();
|
||||
|
||||
// Add to sync history
|
||||
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -1008,6 +1008,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1653,7 +1654,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1675,7 +1675,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1692,7 +1691,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1707,7 +1705,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -5673,6 +5670,7 @@
|
||||
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
@@ -5702,6 +5700,7 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -5980,7 +5979,8 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
@@ -6012,6 +6012,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6044,6 +6045,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6516,6 +6518,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7123,8 +7126,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7364,6 +7366,7 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -7689,7 +7692,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7710,7 +7712,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7928,6 +7929,7 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10167,7 +10169,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -10180,6 +10181,7 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -10826,7 +10828,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10844,7 +10845,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10957,6 +10957,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10966,6 +10967,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11833,7 +11835,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11898,7 +11899,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -11967,6 +11967,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12091,6 +12092,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12293,6 +12295,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12386,6 +12389,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12648,6 +12652,7 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user