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:doctor": "pnpm dlx expo-doctor@latest",
"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": {
"@expo/vector-icons": "^15.0.2",

View File

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

View File

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

View File

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

View File

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

View File

@@ -82,9 +82,9 @@ function makeBetterSecureStore<
const unsafedRootManifest: unknown = rawRootManifestString
? JSON.parse(rawRootManifestString)
: {
manifestVersion: rootManifestVersion,
manifestChunksIds: [],
};
manifestVersion: rootManifestVersion,
manifestChunksIds: [],
};
const rootManifest = rootManifestSchema.parse(unsafedRootManifest);
const manifestChunks = await Promise.all(
rootManifest.manifestChunksIds.map(async (manifestChunkId) => {
@@ -290,6 +290,7 @@ function makeBetterSecureStore<
}),
...valueChunks.map(async (vChunk, chunkIdx) => {
const entryKeyString = entryKey(newManifestEntry.id, chunkIdx);
console.log('DEBUG: setting entry chunk', entryKeyString);
await SecureStore.setItemAsync(entryKeyString, vChunk);
log(
`Set entry chunk for ${entryKeyString} ${chunkIdx} to ${vChunk.length} bytes`,
@@ -325,20 +326,23 @@ const betterKeyStorage = makeBetterSecureStore<KeyMetadata>({
});
async function upsertPrivateKey(params: {
keyId: string;
keyId?: string;
metadata: StrictOmit<KeyMetadata, 'createdAtMs'>;
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
const existing = await betterKeyStorage
.getEntry(params.keyId)
.getEntry(keyId)
.catch(() => undefined);
const createdAtMs =
existing?.manifestEntry.metadata.createdAtMs ?? Date.now();
await betterKeyStorage.upsertEntry({
id: params.keyId,
id: keyId,
metadata: {
...params.metadata,
createdAtMs,
@@ -393,6 +397,7 @@ const betterConnectionStorage = makeBetterSecureStore({
priority: z.number(),
createdAtMs: z.int(),
modifiedAtMs: z.int(),
label: z.string().optional(),
}),
parseValue: (value) => connectionDetailsSchema.parse(JSON.parse(value)),
});
@@ -400,16 +405,18 @@ const betterConnectionStorage = makeBetterSecureStore({
export type InputConnectionDetails = z.infer<typeof connectionDetailsSchema>;
async function upsertConnection(params: {
id: string;
details: InputConnectionDetails;
priority: number;
label?: string;
}) {
const id = `${params.details.username}-${params.details.host}-${params.details.port}`.replaceAll('.', '_');
await betterConnectionStorage.upsertEntry({
id: params.id,
id,
metadata: {
priority: params.priority,
createdAtMs: Date.now(),
modifiedAtMs: Date.now(),
createdAtMs: Date.now(),
label: params.label,
},
value: JSON.stringify(params.details),
});
@@ -436,32 +443,13 @@ const getConnectionQueryOptions = (id: string) =>
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 = {
keys: {
utils: {
upsertPrivateKey,
deletePrivateKey,
generateKeyPair,
listEntriesWithValues: betterKeyStorage.listEntriesWithValues,
getPrivateKey: (keyId: string) => betterKeyStorage.getEntry(keyId),
// Intentionally no specialized setters; use upsertPrivateKey instead.
},
query: {
list: listKeysQueryOptions,

View File

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

View File

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