fix runtime bug

This commit is contained in:
EthanShoeDev
2025-09-19 23:05:34 -04:00
parent fc681f942d
commit f0321c48e7
8 changed files with 76 additions and 73 deletions

View File

@@ -21,7 +21,9 @@
"expo:dep:check": "expo install --fix", "expo:dep:check": "expo install --fix",
"expo:doctor": "pnpm dlx expo-doctor@latest", "expo:doctor": "pnpm dlx expo-doctor@latest",
"test:e2e": "maestro test test/e2e/", "test:e2e": "maestro test test/e2e/",
"adb:logs": "while ! adb logcat --pid=$(adb shell pidof -s dev.fressh.app); do sleep 1; done" "adb:logs": "while ! adb logcat --pid=$(adb shell pidof -s dev.fressh.app); do sleep 1; done",
"android": "expo run:android",
"ios": "expo run:ios"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",

View File

@@ -683,13 +683,10 @@ function ConnectionRow(props: {
} }
// Recreate under new id then delete old // Recreate under new id then delete old
await secretsManager.connections.utils.upsertConnection({ await secretsManager.connections.utils.upsertConnection({
id: newId,
details, details,
priority: 0, priority: 0,
label: newId,
}); });
await secretsManager.connections.utils.deleteConnection(
props.id,
);
await listQuery.refetch(); await listQuery.refetch();
setRenameOpen(false); setRenameOpen(false);
}} }}

View File

@@ -16,6 +16,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useShallow } from 'zustand/react/shallow';
import { preferences } from '@/lib/preferences'; import { preferences } from '@/lib/preferences';
import {} from '@/lib/query-fns'; import {} from '@/lib/query-fns';
import { useSshStore } from '@/lib/ssh-store'; import { useSshStore } from '@/lib/ssh-store';
@@ -31,7 +32,9 @@ export default function TabsShellList() {
} }
function ShellContent() { function ShellContent() {
const connections = useSshStore((s) => Object.values(s.connections)); const connections = useSshStore(
useShallow((s) => Object.values(s.connections)),
);
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
@@ -92,7 +95,7 @@ function FlatView({
}: { }: {
setActionTarget: (target: ActionTarget) => void; setActionTarget: (target: ActionTarget) => void;
}) { }) {
const shells = useSshStore((s) => Object.values(s.shells)); const shells = useSshStore(useShallow((s) => Object.values(s.shells)));
return ( return (
<FlashList<SshShell> <FlashList<SshShell>
@@ -125,8 +128,10 @@ function GroupedView({
}) { }) {
const theme = useTheme(); const theme = useTheme();
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({}); const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const connections = useSshStore((s) => Object.values(s.connections)); const connections = useSshStore(
const shells = useSshStore((s) => Object.values(s.shells)); useShallow((s) => Object.values(s.connections)),
);
const shells = useSshStore(useShallow((s) => Object.values(s.shells)));
return ( return (
<FlashList<SshConnection> <FlashList<SshConnection>
data={connections} data={connections}

View File

@@ -1,5 +1,5 @@
import { RnRussh } from '@fressh/react-native-uniffi-russh';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import * as Crypto from 'expo-crypto';
import * as DocumentPicker from 'expo-document-picker'; import * as DocumentPicker from 'expo-document-picker';
import React from 'react'; import React from 'react';
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
@@ -17,13 +17,8 @@ export function KeyList(props: {
const generateMutation = useMutation({ const generateMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const id = `key_${Date.now()}`; const pair = await RnRussh.generateKeyPair('ed25519');
const pair = await secretsManager.keys.utils.generateKeyPair({
type: 'rsa',
keySize: 4096,
});
await secretsManager.keys.utils.upsertPrivateKey({ await secretsManager.keys.utils.upsertPrivateKey({
keyId: id,
metadata: { priority: 0, label: 'New Key', isDefault: false }, metadata: { priority: 0, label: 'New Key', isDefault: false },
value: pair, value: pair,
}); });
@@ -98,9 +93,7 @@ function ImportKeyCard({ onImported }: { onImported: () => void }) {
mutationFn: async () => { mutationFn: async () => {
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) throw new Error('No key content provided'); if (!trimmed) throw new Error('No key content provided');
const keyId = `key_${Crypto.randomUUID()}`;
await secretsManager.keys.utils.upsertPrivateKey({ await secretsManager.keys.utils.upsertPrivateKey({
keyId,
metadata: { metadata: {
priority: 0, priority: 0,
label: label || 'Imported Key', label: label || 'Imported Key',

View File

@@ -19,15 +19,15 @@ export const useSshConnMutation = (opts?: {
const security = const security =
connectionDetails.security.type === 'password' connectionDetails.security.type === 'password'
? { ? {
type: 'password' as const, type: 'password' as const,
password: connectionDetails.security.password, password: connectionDetails.security.password,
} }
: { : {
type: 'key' as const, type: 'key' as const,
privateKey: await secretsManager.keys.utils privateKey: await secretsManager.keys.utils
.getPrivateKey(connectionDetails.security.keyId) .getPrivateKey(connectionDetails.security.keyId)
.then((e) => e.value), .then((e) => e.value),
}; };
const sshConnection = await connect({ const sshConnection = await connect({
host: connectionDetails.host, host: connectionDetails.host,
@@ -42,7 +42,7 @@ export const useSshConnMutation = (opts?: {
}); });
await secretsManager.connections.utils.upsertConnection({ await secretsManager.connections.utils.upsertConnection({
id: sshConnection.connectionId, label: `${connectionDetails.username}@${connectionDetails.host}:${connectionDetails.port}`,
details: connectionDetails, details: connectionDetails,
priority: 0, priority: 0,
}); });

View File

@@ -82,9 +82,9 @@ function makeBetterSecureStore<
const unsafedRootManifest: unknown = rawRootManifestString const unsafedRootManifest: unknown = rawRootManifestString
? JSON.parse(rawRootManifestString) ? JSON.parse(rawRootManifestString)
: { : {
manifestVersion: rootManifestVersion, manifestVersion: rootManifestVersion,
manifestChunksIds: [], manifestChunksIds: [],
}; };
const rootManifest = rootManifestSchema.parse(unsafedRootManifest); const rootManifest = rootManifestSchema.parse(unsafedRootManifest);
const manifestChunks = await Promise.all( const manifestChunks = await Promise.all(
rootManifest.manifestChunksIds.map(async (manifestChunkId) => { rootManifest.manifestChunksIds.map(async (manifestChunkId) => {
@@ -290,6 +290,7 @@ function makeBetterSecureStore<
}), }),
...valueChunks.map(async (vChunk, chunkIdx) => { ...valueChunks.map(async (vChunk, chunkIdx) => {
const entryKeyString = entryKey(newManifestEntry.id, chunkIdx); const entryKeyString = entryKey(newManifestEntry.id, chunkIdx);
console.log('DEBUG: setting entry chunk', entryKeyString);
await SecureStore.setItemAsync(entryKeyString, vChunk); await SecureStore.setItemAsync(entryKeyString, vChunk);
log( log(
`Set entry chunk for ${entryKeyString} ${chunkIdx} to ${vChunk.length} bytes`, `Set entry chunk for ${entryKeyString} ${chunkIdx} to ${vChunk.length} bytes`,
@@ -325,20 +326,23 @@ const betterKeyStorage = makeBetterSecureStore<KeyMetadata>({
}); });
async function upsertPrivateKey(params: { async function upsertPrivateKey(params: {
keyId: string; keyId?: string;
metadata: StrictOmit<KeyMetadata, 'createdAtMs'>; metadata: StrictOmit<KeyMetadata, 'createdAtMs'>;
value: string; value: string;
}) { }) {
log(`Upserting private key ${params.keyId}`); const validKey = RnRussh.validatePrivateKey(params.value);
if (!validKey) throw new Error('Invalid private key');
const keyId = params.keyId ?? `key_${Crypto.randomUUID()}`;
log(`${params.keyId ? 'Upserting' : 'Creating'} private key ${keyId}`);
// Preserve createdAtMs if the entry already exists // Preserve createdAtMs if the entry already exists
const existing = await betterKeyStorage const existing = await betterKeyStorage
.getEntry(params.keyId) .getEntry(keyId)
.catch(() => undefined); .catch(() => undefined);
const createdAtMs = const createdAtMs =
existing?.manifestEntry.metadata.createdAtMs ?? Date.now(); existing?.manifestEntry.metadata.createdAtMs ?? Date.now();
await betterKeyStorage.upsertEntry({ await betterKeyStorage.upsertEntry({
id: params.keyId, id: keyId,
metadata: { metadata: {
...params.metadata, ...params.metadata,
createdAtMs, createdAtMs,
@@ -393,6 +397,7 @@ const betterConnectionStorage = makeBetterSecureStore({
priority: z.number(), priority: z.number(),
createdAtMs: z.int(), createdAtMs: z.int(),
modifiedAtMs: z.int(), modifiedAtMs: z.int(),
label: z.string().optional(),
}), }),
parseValue: (value) => connectionDetailsSchema.parse(JSON.parse(value)), parseValue: (value) => connectionDetailsSchema.parse(JSON.parse(value)),
}); });
@@ -400,16 +405,18 @@ const betterConnectionStorage = makeBetterSecureStore({
export type InputConnectionDetails = z.infer<typeof connectionDetailsSchema>; export type InputConnectionDetails = z.infer<typeof connectionDetailsSchema>;
async function upsertConnection(params: { async function upsertConnection(params: {
id: string;
details: InputConnectionDetails; details: InputConnectionDetails;
priority: number; priority: number;
label?: string;
}) { }) {
const id = `${params.details.username}-${params.details.host}-${params.details.port}`.replaceAll('.', '_');
await betterConnectionStorage.upsertEntry({ await betterConnectionStorage.upsertEntry({
id: params.id, id,
metadata: { metadata: {
priority: params.priority, priority: params.priority,
createdAtMs: Date.now(),
modifiedAtMs: Date.now(), modifiedAtMs: Date.now(),
createdAtMs: Date.now(),
label: params.label,
}, },
value: JSON.stringify(params.details), value: JSON.stringify(params.details),
}); });
@@ -436,32 +443,13 @@ const getConnectionQueryOptions = (id: string) =>
queryFn: () => betterConnectionStorage.getEntry(id), queryFn: () => betterConnectionStorage.getEntry(id),
}); });
// https://github.com/dylankenneally/react-native-ssh-sftp/blob/ea55436d8d40378a8f9dabb95b463739ffb219fa/android/src/main/java/me/keeex/rnssh/RNSshClientModule.java#L101-L119
export type SshPrivateKeyType = 'dsa' | 'rsa' | 'ecdsa' | 'ed25519' | 'ed448';
async function generateKeyPair(params: {
type: SshPrivateKeyType;
passphrase?: string;
keySize?: number;
comment?: string;
}) {
log('DEBUG: generating key pair', params);
const keyPair = await RnRussh.generateKeyPair(
'ed25519',
// params.keySize,
// params.comment ?? '',
);
return keyPair;
}
export const secretsManager = { export const secretsManager = {
keys: { keys: {
utils: { utils: {
upsertPrivateKey, upsertPrivateKey,
deletePrivateKey, deletePrivateKey,
generateKeyPair,
listEntriesWithValues: betterKeyStorage.listEntriesWithValues, listEntriesWithValues: betterKeyStorage.listEntriesWithValues,
getPrivateKey: (keyId: string) => betterKeyStorage.getEntry(keyId), getPrivateKey: (keyId: string) => betterKeyStorage.getEntry(keyId),
// Intentionally no specialized setters; use upsertPrivateKey instead.
}, },
query: { query: {
list: listKeysQueryOptions, list: listKeysQueryOptions,

View File

@@ -33,8 +33,8 @@ export type ConnectionDetails = {
port: number; port: number;
username: string; username: string;
security: security:
| { type: 'password'; password: string } | { type: 'password'; password: string }
| { type: 'key'; privateKey: string }; | { type: 'key'; privateKey: string };
}; };
/** /**
@@ -147,7 +147,13 @@ export type SshShell = {
type RusshApi = { type RusshApi = {
uniffiInitAsync: () => Promise<void>; uniffiInitAsync: () => Promise<void>;
connect: (opts: ConnectOptions) => Promise<SshConnection>; connect: (opts: ConnectOptions) => Promise<SshConnection>;
generateKeyPair: (type: 'rsa' | 'ecdsa' | 'ed25519') => Promise<string>; generateKeyPair: (type: 'rsa' | 'ecdsa' | 'ed25519',
// TODO: Add these
// passphrase?: string;
// keySize?: number;
// comment?: string;
) => Promise<string>;
validatePrivateKey: (key: string) => boolean;
}; };
// #endregion // #endregion
@@ -312,8 +318,8 @@ function wrapConnection(
term: terminalTypeLiteralToEnum[params.term], term: terminalTypeLiteralToEnum[params.term],
onClosedCallback: params.onClosed onClosedCallback: params.onClosed
? { ? {
onChange: (channelId) => params.onClosed!(channelId), onChange: (channelId) => params.onClosed!(channelId),
} }
: undefined, : undefined,
terminalMode: params.terminalMode, terminalMode: params.terminalMode,
terminalPixelSize: params.terminalPixelSize, terminalPixelSize: params.terminalPixelSize,
@@ -332,11 +338,11 @@ async function connect(options: ConnectOptions): Promise<SshConnection> {
const security = const security =
options.security.type === 'password' options.security.type === 'password'
? new GeneratedRussh.Security.Password({ ? new GeneratedRussh.Security.Password({
password: options.security.password, password: options.security.password,
}) })
: new GeneratedRussh.Security.Key({ : new GeneratedRussh.Security.Key({
privateKeyContent: options.security.privateKey, privateKeyContent: options.security.privateKey,
}); });
const sshConnection = await GeneratedRussh.connect( const sshConnection = await GeneratedRussh.connect(
{ {
connectionDetails: { connectionDetails: {
@@ -347,16 +353,16 @@ async function connect(options: ConnectOptions): Promise<SshConnection> {
}, },
onConnectionProgressCallback: options.onConnectionProgress onConnectionProgressCallback: options.onConnectionProgress
? { ? {
onChange: (statusEnum) => onChange: (statusEnum) =>
options.onConnectionProgress!( options.onConnectionProgress!(
sshConnProgressEnumToLiteral[statusEnum] sshConnProgressEnumToLiteral[statusEnum]
), ),
} }
: undefined, : undefined,
onDisconnectedCallback: options.onDisconnected onDisconnectedCallback: options.onDisconnected
? { ? {
onChange: (connectionId) => options.onDisconnected!(connectionId), onChange: (connectionId) => options.onDisconnected!(connectionId),
} }
: undefined, : undefined,
}, },
options.abortSignal ? { signal: options.abortSignal } : undefined options.abortSignal ? { signal: options.abortSignal } : undefined
@@ -373,10 +379,20 @@ async function generateKeyPair(type: 'rsa' | 'ecdsa' | 'ed25519') {
return GeneratedRussh.generateKeyPair(map[type]); return GeneratedRussh.generateKeyPair(map[type]);
} }
function validatePrivateKey(key: string) {
try {
GeneratedRussh.validatePrivateKey(key);
return true;
} catch {
return false;
}
}
// #endregion // #endregion
export const RnRussh = { export const RnRussh = {
uniffiInitAsync: GeneratedRussh.uniffiInitAsync, uniffiInitAsync: GeneratedRussh.uniffiInitAsync,
connect, connect,
generateKeyPair, generateKeyPair,
validatePrivateKey,
} satisfies RusshApi; } satisfies RusshApi;

View File

@@ -8,7 +8,9 @@ const logExternal: boolean = false;
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react({
}),
dts({ dts({
tsconfigPath: './tsconfig.app.json', tsconfigPath: './tsconfig.app.json',
// This makes dist/ look nice but breaks Cmd + Click // This makes dist/ look nice but breaks Cmd + Click