mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
new eslint
This commit is contained in:
@@ -1,27 +1,115 @@
|
|||||||
// https://docs.expo.dev/guides/using-eslint/
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { config as epicConfig } from '@epic-web/config/eslint';
|
import { config as epicConfig } from '@epic-web/config/eslint';
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
|
||||||
|
import react from '@eslint-react/eslint-plugin';
|
||||||
|
import pluginQuery from '@tanstack/eslint-plugin-query';
|
||||||
|
import * as tsParser from '@typescript-eslint/parser';
|
||||||
import { defineConfig } from 'eslint/config';
|
import { defineConfig } from 'eslint/config';
|
||||||
|
import eslintReact from 'eslint-plugin-react';
|
||||||
|
import pluginReactCompiler from 'eslint-plugin-react-compiler';
|
||||||
|
import hooksPlugin from 'eslint-plugin-react-hooks';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
const expoConfig = require('eslint-config-expo/flat');
|
const expoConfig = require('eslint-config-expo/flat');
|
||||||
|
|
||||||
// // Both epic and expo define a 'import' plugin (though not the same package)
|
// Several presets define the same plugin keys which causes conflicts in ESLint flat config
|
||||||
// // We need to pick one or they will conflict.
|
// (e.g. 'import' from different packages, and '@typescript-eslint').
|
||||||
const stripImportPlugin = (config) => {
|
// Remove conflicting plugins from upstream presets so we can control which wins.
|
||||||
if (!config?.plugins?.['import']) return config;
|
const stripPlugins = (config, names) => {
|
||||||
const { import: _removed, ...rest } = config.plugins;
|
if (!config?.plugins) return config;
|
||||||
return {
|
const plugins = { ...config.plugins };
|
||||||
...config,
|
let changed = false;
|
||||||
plugins: rest,
|
for (const name of names) {
|
||||||
};
|
if (plugins[name]) {
|
||||||
|
delete plugins[name];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? { ...config, plugins } : config;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
...expoConfig,
|
// Expo (strip conflicting plugins defined elsewhere)
|
||||||
...epicConfig.map(stripImportPlugin),
|
...expoConfig.map((c) => stripPlugins(c, ['@typescript-eslint'])),
|
||||||
|
// Epic (strip conflicting plugins defined elsewhere)
|
||||||
|
...epicConfig.map((c) => stripPlugins(c, ['import', '@typescript-eslint'])),
|
||||||
|
|
||||||
|
// ts-eslint
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
{
|
{
|
||||||
ignores: ['dist'],
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// tanstack query
|
||||||
|
...pluginQuery.configs['flat/recommended'],
|
||||||
|
|
||||||
|
// @eslint-react/eslint-plugin (smaller version of eslint-plugin-react)
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
...react.configs['recommended-type-checked'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lint eslint disable comments
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- no types
|
||||||
|
comments.recommended,
|
||||||
|
|
||||||
|
// eslint-plugin-react
|
||||||
|
// Terrible flat config support
|
||||||
|
{
|
||||||
|
...eslintReact.configs.flat.recommended,
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
settings: { react: { version: 'detect' } },
|
||||||
|
languageOptions: {
|
||||||
|
...eslintReact.configs.flat.recommended?.languageOptions,
|
||||||
|
globals: {
|
||||||
|
...globals.serviceworker,
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
...eslintReact.configs.flat.recommended?.plugins,
|
||||||
|
'react-hooks': hooksPlugin,
|
||||||
|
'react-compiler': pluginReactCompiler,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...hooksPlugin.configs.recommended.rules,
|
||||||
|
'react/display-name': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'react/jsx-uses-react': 'off',
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react-compiler/react-compiler': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Custom
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'dist',
|
||||||
|
'**/*.d.ts',
|
||||||
|
'**/.expo/**',
|
||||||
|
'prettier.config.mjs',
|
||||||
|
'eslint.config.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.7",
|
||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"p-queue": "^8.1.1",
|
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.4",
|
"react-native": "0.81.4",
|
||||||
@@ -76,6 +75,18 @@
|
|||||||
"@types/react": "~19.1.12",
|
"@types/react": "~19.1.12",
|
||||||
"cmd-ts": "^0.14.1",
|
"cmd-ts": "^0.14.1",
|
||||||
"eslint": "^9.35.0",
|
"eslint": "^9.35.0",
|
||||||
|
"@eslint/js": "^9.35.0",
|
||||||
|
"@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
|
||||||
|
"@eslint-react/eslint-plugin": "^1.53.0",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.86.0",
|
||||||
|
"@typescript-eslint/parser": "^8.44.0",
|
||||||
|
"@typescript-eslint/utils": "^8.43.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"typescript-eslint": "^8.44.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"jiti": "^2.5.1",
|
"jiti": "^2.5.1",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ export const cmd = (
|
|||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
||||||
proc.stdout?.on('data', (data) => {
|
proc.stdout?.on('data', (data: unknown) => {
|
||||||
stdout += data;
|
stdout += String(data);
|
||||||
});
|
});
|
||||||
proc.stderr?.on('data', (data) => {
|
proc.stderr?.on('data', (data: unknown) => {
|
||||||
stderr += data;
|
stderr += String(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.once('SIGTERM', () => {
|
process.once('SIGTERM', () => {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ async function getSecrets(): Promise<{
|
|||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const rawBwItem = JSON.parse(rawBwItemString);
|
|
||||||
const bwItemSchema = z.looseObject({
|
const bwItemSchema = z.looseObject({
|
||||||
login: z.looseObject({
|
login: z.looseObject({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
@@ -32,7 +31,7 @@ async function getSecrets(): Promise<{
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const bwItem = bwItemSchema.parse(rawBwItem, {
|
const bwItem = bwItemSchema.parse(JSON.parse(rawBwItemString) as unknown, {
|
||||||
reportInput: true,
|
reportInput: true,
|
||||||
});
|
});
|
||||||
const keystoreBase64 = bwItem.fields.find(
|
const keystoreBase64 = bwItem.fields.find(
|
||||||
@@ -138,7 +137,7 @@ const signedBuildCommand = command({
|
|||||||
.replace(
|
.replace(
|
||||||
/signingConfigs \{([\s\S]*?)\}/, // Modify existing signingConfigs without removing debug
|
/signingConfigs \{([\s\S]*?)\}/, // Modify existing signingConfigs without removing debug
|
||||||
(match) => {
|
(match) => {
|
||||||
if (/release \{/.test(match)) {
|
if (match.includes('release {')) {
|
||||||
return match.replace(
|
return match.replace(
|
||||||
/release \{([\s\S]*?)\}/,
|
/release \{([\s\S]*?)\}/,
|
||||||
releaseSigningConfig,
|
releaseSigningConfig,
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ export default function KeyManagerModalRoute() {
|
|||||||
options={{
|
options={{
|
||||||
title: selectMode ? 'Select Key' : 'Manage Keys',
|
title: selectMode ? 'Select Key' : 'Manage Keys',
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<Pressable onPress={() => router.back()}>
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text style={{ color: '#E5E7EB', fontWeight: '700' }}>Close</Text>
|
<Text style={{ color: '#E5E7EB', fontWeight: '700' }}>Close</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
),
|
),
|
||||||
@@ -23,7 +27,9 @@ export default function KeyManagerModalRoute() {
|
|||||||
/>
|
/>
|
||||||
<KeyList
|
<KeyList
|
||||||
mode={selectMode ? 'select' : 'manage'}
|
mode={selectMode ? 'select' : 'manage'}
|
||||||
onSelect={async () => router.back()}
|
onSelect={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ function KeyIdPickerField() {
|
|||||||
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
|
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
|
||||||
const defaultPick = React.useMemo(() => {
|
const defaultPick = React.useMemo(() => {
|
||||||
const keys = listPrivateKeysQuery.data ?? [];
|
const keys = listPrivateKeysQuery.data ?? [];
|
||||||
const def = keys.find((k) => k.metadata?.isDefault);
|
const def = keys.find((k) => k.metadata.isDefault);
|
||||||
return def ?? keys[0];
|
return def ?? keys[0];
|
||||||
}, [listPrivateKeysQuery.data]);
|
}, [listPrivateKeysQuery.data]);
|
||||||
const keys = listPrivateKeysQuery.data ?? [];
|
const keys = listPrivateKeysQuery.data ?? [];
|
||||||
@@ -252,9 +252,9 @@ function KeyIdPickerField() {
|
|||||||
}
|
}
|
||||||
}, [fieldValue, defaultPickId, fieldHandleChange]);
|
}, [fieldValue, defaultPickId, fieldHandleChange]);
|
||||||
|
|
||||||
const computedSelectedId = field.state.value ?? defaultPick?.id;
|
const computedSelectedId = field.state.value;
|
||||||
const selected = keys.find((k) => k.id === computedSelectedId);
|
const selected = keys.find((k) => k.id === computedSelectedId);
|
||||||
const display = selected ? (selected.metadata?.label ?? selected.id) : 'None';
|
const display = selected ? (selected.metadata.label ?? selected.id) : 'None';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -298,7 +298,9 @@ function KeyIdPickerField() {
|
|||||||
visible={open}
|
visible={open}
|
||||||
transparent
|
transparent
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
onRequestClose={() => setOpen(false)}
|
onRequestClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -343,7 +345,9 @@ function KeyIdPickerField() {
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
}}
|
}}
|
||||||
onPress={() => setOpen(false)}
|
onPress={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -357,7 +361,7 @@ function KeyIdPickerField() {
|
|||||||
</View>
|
</View>
|
||||||
<KeyList
|
<KeyList
|
||||||
mode="select"
|
mode="select"
|
||||||
onSelect={async (id) => {
|
onSelect={(id) => {
|
||||||
field.handleChange(id);
|
field.handleChange(id);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -399,7 +403,7 @@ function PreviousConnectionsSection(props: {
|
|||||||
</Text>
|
</Text>
|
||||||
) : listConnectionsQuery.data?.length ? (
|
) : listConnectionsQuery.data?.length ? (
|
||||||
<View>
|
<View>
|
||||||
{listConnectionsQuery.data?.map((conn) => (
|
{listConnectionsQuery.data.map((conn) => (
|
||||||
<ConnectionRow
|
<ConnectionRow
|
||||||
key={conn.id}
|
key={conn.id}
|
||||||
id={conn.id}
|
id={conn.id}
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ export default function Tab() {
|
|||||||
<Row
|
<Row
|
||||||
label="Dark"
|
label="Dark"
|
||||||
selected={themeName === 'dark'}
|
selected={themeName === 'dark'}
|
||||||
onPress={() => setThemeName('dark')}
|
onPress={() => {
|
||||||
|
setThemeName('dark');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Light"
|
label="Light"
|
||||||
selected={themeName === 'light'}
|
selected={themeName === 'light'}
|
||||||
onPress={() => setThemeName('light')}
|
onPress={() => {
|
||||||
|
setThemeName('light');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import {
|
import { type ListenerEvent } from '@fressh/react-native-uniffi-russh';
|
||||||
type ListenerEvent,
|
|
||||||
type TerminalChunk,
|
|
||||||
} from '@fressh/react-native-uniffi-russh';
|
|
||||||
import {
|
import {
|
||||||
XtermJsWebView,
|
XtermJsWebView,
|
||||||
type XtermWebViewHandle,
|
type XtermWebViewHandle,
|
||||||
@@ -16,11 +13,14 @@ import {
|
|||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
} from 'expo-router';
|
} from 'expo-router';
|
||||||
import React, { startTransition, useEffect, useRef, useState } from 'react';
|
import React, { startTransition, useEffect, useRef, useState } from 'react';
|
||||||
import { Pressable, View, Text } from 'react-native';
|
import { Dimensions, Platform, Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import {
|
||||||
|
SafeAreaView,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from 'react-native-safe-area-context';
|
||||||
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
|
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
|
||||||
import { getSession } from '@/lib/ssh-registry';
|
import { useSshStore, makeSessionKey } from '@/lib/ssh-store';
|
||||||
import { useTheme } from '@/lib/theme';
|
import { useTheme } from '@/lib/theme';
|
||||||
|
|
||||||
export default function TabsShellDetail() {
|
export default function TabsShellDetail() {
|
||||||
@@ -28,9 +28,13 @@ export default function TabsShellDetail() {
|
|||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
startTransition(() => setReady(true)); // React 19: non-urgent
|
startTransition(() => {
|
||||||
|
setReady(true);
|
||||||
|
}); // React 19: non-urgent
|
||||||
|
|
||||||
return () => setReady(false);
|
return () => {
|
||||||
|
setReady(false);
|
||||||
|
};
|
||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,10 +64,11 @@ function ShellDetail() {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const channelIdNum = Number(channelId);
|
const channelIdNum = Number(channelId);
|
||||||
const sess =
|
const sess = useSshStore((s) =>
|
||||||
connectionId && channelId
|
connectionId && channelId
|
||||||
? getSession(String(connectionId), channelIdNum)
|
? s.getByKey(makeSessionKey(connectionId, channelIdNum))
|
||||||
: undefined;
|
: undefined,
|
||||||
|
);
|
||||||
const connection = sess?.connection;
|
const connection = sess?.connection;
|
||||||
const shell = sess?.shell;
|
const shell = sess?.shell;
|
||||||
|
|
||||||
@@ -74,14 +79,41 @@ function ShellDetail() {
|
|||||||
if (shell && listenerIdRef.current != null)
|
if (shell && listenerIdRef.current != null)
|
||||||
shell.removeListener(listenerIdRef.current);
|
shell.removeListener(listenerIdRef.current);
|
||||||
listenerIdRef.current = null;
|
listenerIdRef.current = null;
|
||||||
xterm?.flush?.();
|
if (xterm) xterm.flush();
|
||||||
};
|
};
|
||||||
}, [shell]);
|
}, [shell]);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const estimatedTabBarHeight = Platform.select({
|
||||||
|
ios: 49,
|
||||||
|
android: 80,
|
||||||
|
default: 56,
|
||||||
|
});
|
||||||
|
const windowH = Dimensions.get('window').height;
|
||||||
|
const computeBottomExtra = (y: number, height: number) => {
|
||||||
|
const extra = windowH - (y + height);
|
||||||
|
return extra > 0 ? extra : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Measure any bottom overlap (e.g., native tab bar) and add padding to avoid it
|
||||||
|
const [bottomExtra, setBottomExtra] = useState(0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
<SafeAreaView
|
||||||
|
onLayout={(e) => {
|
||||||
|
const { y, height } = e.nativeEvent.layout;
|
||||||
|
const extra = computeBottomExtra(y, height);
|
||||||
|
if (extra !== bottomExtra) setBottomExtra(extra);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
padding: 12,
|
||||||
|
paddingBottom:
|
||||||
|
12 + insets.bottom + (bottomExtra || estimatedTabBarHeight),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
options={{
|
options={{
|
||||||
headerBackVisible: true,
|
headerBackVisible: true,
|
||||||
@@ -107,98 +139,101 @@ function ShellDetail() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View
|
<XtermJsWebView
|
||||||
style={[
|
ref={xtermRef}
|
||||||
{ flex: 1, backgroundColor: '#0B1324', padding: 12 },
|
style={{ flex: 1 }}
|
||||||
{ backgroundColor: theme.colors.background },
|
// WebView behavior that suits terminals
|
||||||
]}
|
keyboardDisplayRequiresUserAction={false}
|
||||||
>
|
setSupportMultipleWindows={false}
|
||||||
<XtermJsWebView
|
overScrollMode="never"
|
||||||
ref={xtermRef}
|
pullToRefreshEnabled={false}
|
||||||
style={{ flex: 1 }}
|
bounces={false}
|
||||||
// WebView behavior that suits terminals
|
setBuiltInZoomControls={false}
|
||||||
keyboardDisplayRequiresUserAction={false}
|
setDisplayZoomControls={false}
|
||||||
setSupportMultipleWindows={false}
|
textZoom={100}
|
||||||
overScrollMode="never"
|
allowsLinkPreview={false}
|
||||||
pullToRefreshEnabled={false}
|
textInteractionEnabled={false}
|
||||||
bounces={false}
|
// xterm-ish props (applied via setOptions inside the page)
|
||||||
setBuiltInZoomControls={false}
|
fontFamily="Menlo, ui-monospace, monospace"
|
||||||
setDisplayZoomControls={false}
|
fontSize={18} // bump if it still feels small
|
||||||
textZoom={100}
|
cursorBlink
|
||||||
allowsLinkPreview={false}
|
scrollback={10000}
|
||||||
textInteractionEnabled={false}
|
themeBackground={theme.colors.background}
|
||||||
// xterm-ish props (applied via setOptions inside the page)
|
themeForeground={theme.colors.textPrimary}
|
||||||
fontFamily="Menlo, ui-monospace, monospace"
|
onRenderProcessGone={() => {
|
||||||
fontSize={18} // bump if it still feels small
|
console.log('WebView render process gone -> clear()');
|
||||||
cursorBlink
|
const xr = xtermRef.current;
|
||||||
scrollback={10000}
|
if (xr) xr.clear();
|
||||||
themeBackground={theme.colors.background}
|
}}
|
||||||
themeForeground={theme.colors.textPrimary}
|
onContentProcessDidTerminate={() => {
|
||||||
onRenderProcessGone={() => {
|
console.log('WKWebView content process terminated -> clear()');
|
||||||
console.log('WebView render process gone -> clear()');
|
const xr = xtermRef.current;
|
||||||
xtermRef.current?.clear?.();
|
if (xr) xr.clear();
|
||||||
}}
|
}}
|
||||||
onContentProcessDidTerminate={() => {
|
onLoadEnd={() => {
|
||||||
console.log('WKWebView content process terminated -> clear()');
|
console.log('WebView onLoadEnd');
|
||||||
xtermRef.current?.clear?.();
|
}}
|
||||||
}}
|
onMessage={(m) => {
|
||||||
onLoadEnd={() => {
|
console.log('received msg', m);
|
||||||
console.log('WebView onLoadEnd');
|
if (m.type === 'initialized') {
|
||||||
}}
|
if (terminalReadyRef.current) return;
|
||||||
onMessage={(m) => {
|
terminalReadyRef.current = true;
|
||||||
console.log('received msg', m);
|
|
||||||
if (m.type === 'initialized') {
|
|
||||||
if (terminalReadyRef.current) return;
|
|
||||||
terminalReadyRef.current = true;
|
|
||||||
|
|
||||||
// Replay from head, then attach live listener
|
// Replay from head, then attach live listener
|
||||||
if (shell) {
|
if (shell) {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const res = await shell.readBuffer({ mode: 'head' });
|
const res = await shell.readBuffer({ mode: 'head' });
|
||||||
console.log('readBuffer(head)', {
|
console.log('readBuffer(head)', {
|
||||||
chunks: res.chunks.length,
|
chunks: res.chunks.length,
|
||||||
nextSeq: res.nextSeq,
|
nextSeq: res.nextSeq,
|
||||||
dropped: res.dropped,
|
dropped: res.dropped,
|
||||||
});
|
});
|
||||||
if (res.chunks.length) {
|
if (res.chunks.length) {
|
||||||
const chunks = res.chunks.map((c) => c.bytes);
|
const chunks = res.chunks.map((c) => c.bytes);
|
||||||
xtermRef.current?.writeMany?.(chunks);
|
const xr = xtermRef.current;
|
||||||
xtermRef.current?.flush?.();
|
if (xr) {
|
||||||
|
xr.writeMany(chunks);
|
||||||
|
xr.flush();
|
||||||
}
|
}
|
||||||
const id = shell.addListener(
|
}
|
||||||
(ev: ListenerEvent) => {
|
const id = shell.addListener(
|
||||||
if ('kind' in ev && ev.kind === 'dropped') {
|
(ev: ListenerEvent) => {
|
||||||
console.log('listener.dropped', ev);
|
if ('kind' in ev) {
|
||||||
return;
|
console.log('listener.dropped', ev);
|
||||||
}
|
return;
|
||||||
const chunk = ev as TerminalChunk;
|
}
|
||||||
xtermRef.current?.write(chunk.bytes);
|
const chunk = ev;
|
||||||
},
|
const xr3 = xtermRef.current;
|
||||||
{ cursor: { mode: 'seq', seq: res.nextSeq } },
|
if (xr3) xr3.write(chunk.bytes);
|
||||||
);
|
},
|
||||||
console.log('shell listener attached', id.toString());
|
{ cursor: { mode: 'seq', seq: res.nextSeq } },
|
||||||
listenerIdRef.current = id;
|
);
|
||||||
})();
|
console.log('shell listener attached', id.toString());
|
||||||
}
|
listenerIdRef.current = id;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
// Focus to pop the keyboard (iOS needs the prop we set)
|
// Focus to pop the keyboard (iOS needs the prop we set)
|
||||||
xtermRef.current?.focus?.();
|
const xr2 = xtermRef.current;
|
||||||
return;
|
if (xr2) xr2.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.type === 'data') {
|
||||||
|
console.log('xterm->SSH', { len: m.data.length });
|
||||||
|
const { buffer, byteOffset, byteLength } = m.data;
|
||||||
|
const ab = buffer.slice(byteOffset, byteOffset + byteLength);
|
||||||
|
if (shell) {
|
||||||
|
shell.sendData(ab as ArrayBuffer).catch((e: unknown) => {
|
||||||
|
console.warn('sendData failed', e);
|
||||||
|
router.back();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (m.type === 'data') {
|
return;
|
||||||
console.log('xterm->SSH', { len: m.data.length });
|
} else {
|
||||||
// Ensure we send the exact slice; send CR only for Enter.
|
console.log('xterm.debug', m.message);
|
||||||
const { buffer, byteOffset, byteLength } = m.data;
|
}
|
||||||
const ab = buffer.slice(byteOffset, byteOffset + byteLength);
|
}}
|
||||||
void shell?.sendData(ab as ArrayBuffer);
|
/>
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (m.type === 'debug') {
|
|
||||||
console.log('xterm.debug', m.message);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { type SshConnection } from '@fressh/react-native-uniffi-russh';
|
import {
|
||||||
|
type SshShell,
|
||||||
|
type SshConnection,
|
||||||
|
} from '@fressh/react-native-uniffi-russh';
|
||||||
import { FlashList } from '@shopify/flash-list';
|
import { FlashList } from '@shopify/flash-list';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
@@ -21,7 +24,6 @@ import {
|
|||||||
listSshShellsQueryOptions,
|
listSshShellsQueryOptions,
|
||||||
type ShellWithConnection,
|
type ShellWithConnection,
|
||||||
} from '@/lib/query-fns';
|
} from '@/lib/query-fns';
|
||||||
import { type listConnectionsWithShells as registryList } from '@/lib/ssh-registry';
|
|
||||||
import { useTheme } from '@/lib/theme';
|
import { useTheme } from '@/lib/theme';
|
||||||
|
|
||||||
export default function TabsShellList() {
|
export default function TabsShellList() {
|
||||||
@@ -78,7 +80,7 @@ type ActionTarget =
|
|||||||
connection: SshConnection;
|
connection: SshConnection;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConnectionsList = ReturnType<typeof registryList>;
|
type ConnectionsList = (SshConnection & { shells: SshShell[] })[];
|
||||||
|
|
||||||
function LoadedState({ connections }: { connections: ConnectionsList }) {
|
function LoadedState({ connections }: { connections: ConnectionsList }) {
|
||||||
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
|
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
|
||||||
@@ -103,7 +105,9 @@ function LoadedState({ connections }: { connections: ConnectionsList }) {
|
|||||||
)}
|
)}
|
||||||
<ActionsSheet
|
<ActionsSheet
|
||||||
target={actionTarget}
|
target={actionTarget}
|
||||||
onClose={() => setActionTarget(null)}
|
onClose={() => {
|
||||||
|
setActionTarget(null);
|
||||||
|
}}
|
||||||
onCloseShell={() => {
|
onCloseShell={() => {
|
||||||
if (!actionTarget) return;
|
if (!actionTarget) return;
|
||||||
if (!('shell' in actionTarget)) return;
|
if (!('shell' in actionTarget)) return;
|
||||||
@@ -138,7 +142,12 @@ function FlatView({
|
|||||||
}) {
|
}) {
|
||||||
const flatShells = React.useMemo(() => {
|
const flatShells = React.useMemo(() => {
|
||||||
return connectionsWithShells.reduce<ShellWithConnection[]>((acc, curr) => {
|
return connectionsWithShells.reduce<ShellWithConnection[]>((acc, curr) => {
|
||||||
acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr })));
|
acc.push(
|
||||||
|
...curr.shells.map((shell) => ({
|
||||||
|
...shell,
|
||||||
|
connection: curr,
|
||||||
|
})),
|
||||||
|
);
|
||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
}, [connectionsWithShells]);
|
}, [connectionsWithShells]);
|
||||||
@@ -149,11 +158,11 @@ function FlatView({
|
|||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<ShellCard
|
<ShellCard
|
||||||
shell={item}
|
shell={item}
|
||||||
onLongPress={() =>
|
onLongPress={() => {
|
||||||
setActionTarget({
|
setActionTarget({
|
||||||
shell: item,
|
shell: item,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
|
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
|
||||||
@@ -194,12 +203,12 @@ function GroupedView({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
}}
|
}}
|
||||||
onPress={() =>
|
onPress={() => {
|
||||||
setExpanded((prev) => ({
|
setExpanded((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[item.connectionId]: !prev[item.connectionId],
|
[item.connectionId]: !prev[item.connectionId],
|
||||||
}))
|
}));
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
@@ -234,11 +243,11 @@ function GroupedView({
|
|||||||
<ShellCard
|
<ShellCard
|
||||||
key={`${sh.connectionId}:${sh.channelId}`}
|
key={`${sh.connectionId}:${sh.channelId}`}
|
||||||
shell={shellWithConnection}
|
shell={shellWithConnection}
|
||||||
onLongPress={() =>
|
onLongPress={() => {
|
||||||
setActionTarget({
|
setActionTarget({
|
||||||
shell: shellWithConnection,
|
shell: shellWithConnection,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -299,15 +308,15 @@ function ShellCard({
|
|||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
}}
|
}}
|
||||||
onPress={() =>
|
onPress={() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/shell/detail',
|
pathname: '/shell/detail',
|
||||||
params: {
|
params: {
|
||||||
connectionId: String(shell.connectionId),
|
connectionId: shell.connectionId,
|
||||||
channelId: String(shell.channelId),
|
channelId: String(shell.channelId),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
onLongPress={onLongPress}
|
onLongPress={onLongPress}
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ import {
|
|||||||
|
|
||||||
function FieldInfo() {
|
function FieldInfo() {
|
||||||
const field = useFieldContext();
|
const field = useFieldContext();
|
||||||
const meta = field.state.meta;
|
const meta = field.state.meta as { errors?: unknown[] };
|
||||||
const errorMessage = meta?.errors?.[0]; // TODO: typesafe errors
|
const errs = meta.errors;
|
||||||
|
const errorMessage = errs && errs.length > 0 ? String(errs[0]) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 6 }}>
|
<View style={{ marginTop: 6 }}>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<Text style={{ color: '#FCA5A5', fontSize: 12 }}>
|
<Text style={{ color: '#FCA5A5', fontSize: 12 }}>{errorMessage}</Text>
|
||||||
{String(errorMessage)}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -103,7 +102,9 @@ export function NumberField(
|
|||||||
]}
|
]}
|
||||||
placeholderTextColor="#9AA0A6"
|
placeholderTextColor="#9AA0A6"
|
||||||
value={field.state.value.toString()}
|
value={field.state.value.toString()}
|
||||||
onChangeText={(text) => field.handleChange(Number(text))}
|
onChangeText={(text) => {
|
||||||
|
field.handleChange(Number(text));
|
||||||
|
}}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
@@ -145,7 +146,9 @@ export function SwitchField(
|
|||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.nativeEvent.value)}
|
onChange={(event) => {
|
||||||
|
field.handleChange(event.nativeEvent.value);
|
||||||
|
}}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
@@ -179,7 +182,7 @@ export function SubmitButton(
|
|||||||
disabled ? { backgroundColor: '#3B82F6', opacity: 0.6 } : undefined,
|
disabled ? { backgroundColor: '#3B82F6', opacity: 0.6 } : undefined,
|
||||||
]}
|
]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={disabled || isSubmitting}
|
disabled={disabled === true ? true : isSubmitting}
|
||||||
>
|
>
|
||||||
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 16 }}>
|
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 16 }}>
|
||||||
{isSubmitting ? 'Connecting...' : title}
|
{isSubmitting ? 'Connecting...' : title}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export function KeyList(props: {
|
|||||||
generateMutation.isPending && { opacity: 0.7 },
|
generateMutation.isPending && { opacity: 0.7 },
|
||||||
]}
|
]}
|
||||||
disabled={generateMutation.isPending}
|
disabled={generateMutation.isPending}
|
||||||
onPress={() => generateMutation.mutate()}
|
onPress={() => {
|
||||||
|
generateMutation.mutate();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 14 }}>
|
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 14 }}>
|
||||||
{generateMutation.isPending
|
{generateMutation.isPending
|
||||||
@@ -80,7 +82,7 @@ function KeyRow(props: {
|
|||||||
const entryQuery = useQuery(secretsManager.keys.query.get(props.entryId));
|
const entryQuery = useQuery(secretsManager.keys.query.get(props.entryId));
|
||||||
const entry = entryQuery.data;
|
const entry = entryQuery.data;
|
||||||
const [label, setLabel] = React.useState(
|
const [label, setLabel] = React.useState(
|
||||||
entry?.manifestEntry.metadata?.label ?? '',
|
entry?.manifestEntry.metadata.label ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
const renameMutation = useMutation({
|
const renameMutation = useMutation({
|
||||||
@@ -150,8 +152,8 @@ function KeyRow(props: {
|
|||||||
>
|
>
|
||||||
<View style={{ flex: 1, marginRight: 8 }}>
|
<View style={{ flex: 1, marginRight: 8 }}>
|
||||||
<Text style={{ color: '#E5E7EB', fontSize: 15, fontWeight: '600' }}>
|
<Text style={{ color: '#E5E7EB', fontSize: 15, fontWeight: '600' }}>
|
||||||
{entry.manifestEntry.metadata?.label ?? entry.manifestEntry.id}
|
{entry.manifestEntry.metadata.label ?? entry.manifestEntry.id}
|
||||||
{entry.manifestEntry.metadata?.isDefault ? ' • Default' : ''}
|
{entry.manifestEntry.metadata.isDefault ? ' • Default' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ color: '#9AA0A6', fontSize: 12, marginTop: 2 }}>
|
<Text style={{ color: '#9AA0A6', fontSize: 12, marginTop: 2 }}>
|
||||||
ID: {entry.manifestEntry.id}
|
ID: {entry.manifestEntry.id}
|
||||||
@@ -179,7 +181,9 @@ function KeyRow(props: {
|
|||||||
<View style={{ gap: 6, alignItems: 'flex-end' }}>
|
<View style={{ gap: 6, alignItems: 'flex-end' }}>
|
||||||
{props.mode === 'select' ? (
|
{props.mode === 'select' ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setDefaultMutation.mutate()}
|
onPress={() => {
|
||||||
|
setDefaultMutation.mutate();
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#2563EB',
|
backgroundColor: '#2563EB',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
@@ -207,7 +211,9 @@ function KeyRow(props: {
|
|||||||
},
|
},
|
||||||
renameMutation.isPending && { opacity: 0.6 },
|
renameMutation.isPending && { opacity: 0.6 },
|
||||||
]}
|
]}
|
||||||
onPress={() => renameMutation.mutate(label)}
|
onPress={() => {
|
||||||
|
renameMutation.mutate(label);
|
||||||
|
}}
|
||||||
disabled={renameMutation.isPending}
|
disabled={renameMutation.isPending}
|
||||||
>
|
>
|
||||||
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
|
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
|
||||||
@@ -215,7 +221,7 @@ function KeyRow(props: {
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
{!entry.manifestEntry.metadata?.isDefault ? (
|
{!entry.manifestEntry.metadata.isDefault ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@@ -226,7 +232,9 @@ function KeyRow(props: {
|
|||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
onPress={() => setDefaultMutation.mutate()}
|
onPress={() => {
|
||||||
|
setDefaultMutation.mutate();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
|
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
|
||||||
Set Default
|
Set Default
|
||||||
@@ -243,7 +251,9 @@ function KeyRow(props: {
|
|||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
onPress={() => deleteMutation.mutate()}
|
onPress={() => {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ color: '#FCA5A5', fontWeight: '700', fontSize: 12 }}>
|
<Text style={{ color: '#FCA5A5', fontWeight: '700', fontSize: 12 }}>
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export const preferences = {
|
|||||||
rawTheme === 'light' ? 'light' : 'dark',
|
rawTheme === 'light' ? 'light' : 'dark',
|
||||||
get: (): ThemeName =>
|
get: (): ThemeName =>
|
||||||
preferences.theme._resolve(storage.getString(preferences.theme._key)),
|
preferences.theme._resolve(storage.getString(preferences.theme._key)),
|
||||||
set: (name: ThemeName) => storage.set(preferences.theme._key, name),
|
set: (name: ThemeName) => {
|
||||||
|
storage.set(preferences.theme._key, name);
|
||||||
|
},
|
||||||
useThemePref: (): [ThemeName, (name: ThemeName) => void] => {
|
useThemePref: (): [ThemeName, (name: ThemeName) => void] => {
|
||||||
const [theme, setTheme] = useMMKVString(preferences.theme._key);
|
const [theme, setTheme] = useMMKVString(preferences.theme._key);
|
||||||
return [
|
return [
|
||||||
@@ -31,8 +33,9 @@ export const preferences = {
|
|||||||
preferences.shellListViewMode._resolve(
|
preferences.shellListViewMode._resolve(
|
||||||
storage.getString(preferences.shellListViewMode._key),
|
storage.getString(preferences.shellListViewMode._key),
|
||||||
),
|
),
|
||||||
set: (mode: ShellListViewMode) =>
|
set: (mode: ShellListViewMode) => {
|
||||||
storage.set(preferences.shellListViewMode._key, mode),
|
storage.set(preferences.shellListViewMode._key, mode);
|
||||||
|
},
|
||||||
|
|
||||||
useShellListViewModePref: (): [
|
useShellListViewModePref: (): [
|
||||||
ShellListViewMode,
|
ShellListViewMode,
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import {
|
|||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { secretsManager, type InputConnectionDetails } from './secrets-manager';
|
import { secretsManager, type InputConnectionDetails } from './secrets-manager';
|
||||||
import {
|
import { useSshStore, toSessionStatus, type SessionKey } from './ssh-store';
|
||||||
listConnectionsWithShells as registryList,
|
|
||||||
registerSession,
|
|
||||||
type ShellWithConnection,
|
|
||||||
} from './ssh-registry';
|
|
||||||
import { AbortSignalTimeout } from './utils';
|
import { AbortSignalTimeout } from './utils';
|
||||||
|
|
||||||
export const useSshConnMutation = () => {
|
export const useSshConnMutation = () => {
|
||||||
@@ -44,22 +40,26 @@ export const useSshConnMutation = () => {
|
|||||||
details: connectionDetails,
|
details: connectionDetails,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
});
|
});
|
||||||
|
// Capture status events to Zustand after session is known.
|
||||||
|
let keyRef: SessionKey | null = null;
|
||||||
const shellInterface = await sshConnection.startShell({
|
const shellInterface = await sshConnection.startShell({
|
||||||
pty: 'Xterm',
|
pty: 'Xterm',
|
||||||
onStatusChange: (status) => {
|
onStatusChange: (status) => {
|
||||||
|
if (keyRef)
|
||||||
|
useSshStore.getState().setStatus(keyRef, toSessionStatus(status));
|
||||||
console.log('SSH shell status', status);
|
console.log('SSH shell status', status);
|
||||||
},
|
},
|
||||||
abortSignal: AbortSignalTimeout(5_000),
|
abortSignal: AbortSignalTimeout(5_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelId = shellInterface.channelId as number;
|
const channelId = shellInterface.channelId;
|
||||||
const connectionId =
|
const connectionId = `${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
||||||
sshConnection.connectionId ??
|
|
||||||
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
|
||||||
console.log('Connected to SSH server', connectionId, channelId);
|
console.log('Connected to SSH server', connectionId, channelId);
|
||||||
|
|
||||||
// Track in registry for app use
|
// Track in Zustand store
|
||||||
registerSession(sshConnection, shellInterface);
|
keyRef = useSshStore
|
||||||
|
.getState()
|
||||||
|
.addSession(sshConnection, shellInterface);
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: listSshShellsQueryOptions.queryKey,
|
queryKey: listSshShellsQueryOptions.queryKey,
|
||||||
@@ -81,17 +81,29 @@ export const useSshConnMutation = () => {
|
|||||||
|
|
||||||
export const listSshShellsQueryOptions = queryOptions({
|
export const listSshShellsQueryOptions = queryOptions({
|
||||||
queryKey: ['ssh-shells'],
|
queryKey: ['ssh-shells'],
|
||||||
queryFn: () => registryList(),
|
queryFn: () => useSshStore.getState().listConnectionsWithShells(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type { ShellWithConnection };
|
export type ShellWithConnection = (ReturnType<
|
||||||
|
typeof useSshStore.getState
|
||||||
|
>['listConnectionsWithShells'] extends () => infer R
|
||||||
|
? R
|
||||||
|
: never)[number]['shells'][number] & {
|
||||||
|
connection: (ReturnType<
|
||||||
|
typeof useSshStore.getState
|
||||||
|
>['listConnectionsWithShells'] extends () => infer R
|
||||||
|
? R
|
||||||
|
: never)[number];
|
||||||
|
};
|
||||||
|
|
||||||
export const closeSshShellAndInvalidateQuery = async (params: {
|
export const closeSshShellAndInvalidateQuery = async (params: {
|
||||||
channelId: number;
|
channelId: number;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
}) => {
|
}) => {
|
||||||
const currentActiveShells = registryList();
|
const currentActiveShells = useSshStore
|
||||||
|
.getState()
|
||||||
|
.listConnectionsWithShells();
|
||||||
const connection = currentActiveShells.find(
|
const connection = currentActiveShells.find(
|
||||||
(c) => c.connectionId === params.connectionId,
|
(c) => c.connectionId === params.connectionId,
|
||||||
);
|
);
|
||||||
@@ -108,7 +120,9 @@ export const disconnectSshConnectionAndInvalidateQuery = async (params: {
|
|||||||
connectionId: string;
|
connectionId: string;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
}) => {
|
}) => {
|
||||||
const currentActiveShells = registryList();
|
const currentActiveShells = useSshStore
|
||||||
|
.getState()
|
||||||
|
.listConnectionsWithShells();
|
||||||
const connection = currentActiveShells.find(
|
const connection = currentActiveShells.find(
|
||||||
(c) => c.connectionId === params.connectionId,
|
(c) => c.connectionId === params.connectionId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function makeBetterSecureStore<
|
|||||||
log(
|
log(
|
||||||
`Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`,
|
`Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`,
|
||||||
);
|
);
|
||||||
const unsafedRootManifest = rawRootManifestString
|
const unsafedRootManifest: unknown = rawRootManifestString
|
||||||
? JSON.parse(rawRootManifestString)
|
? JSON.parse(rawRootManifestString)
|
||||||
: {
|
: {
|
||||||
manifestVersion: rootManifestVersion,
|
manifestVersion: rootManifestVersion,
|
||||||
@@ -95,9 +95,11 @@ function makeBetterSecureStore<
|
|||||||
if (!rawManifestChunkString)
|
if (!rawManifestChunkString)
|
||||||
throw new Error('Manifest chunk not found');
|
throw new Error('Manifest chunk not found');
|
||||||
log(
|
log(
|
||||||
`Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString?.length} bytes`,
|
`Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString.length} bytes`,
|
||||||
|
);
|
||||||
|
const unsafedManifestChunk: unknown = JSON.parse(
|
||||||
|
rawManifestChunkString,
|
||||||
);
|
);
|
||||||
const unsafedManifestChunk = JSON.parse(rawManifestChunkString);
|
|
||||||
return {
|
return {
|
||||||
manifestChunk: manifestChunkSchema.parse(unsafedManifestChunk),
|
manifestChunk: manifestChunkSchema.parse(unsafedManifestChunk),
|
||||||
manifestChunkId,
|
manifestChunkId,
|
||||||
@@ -316,7 +318,7 @@ const keyMetadataSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type KeyMetadata = z.infer<typeof keyMetadataSchema>;
|
export type KeyMetadata = z.infer<typeof keyMetadataSchema>;
|
||||||
|
|
||||||
const betterKeyStorage = makeBetterSecureStore<KeyMetadata, string>({
|
const betterKeyStorage = makeBetterSecureStore<KeyMetadata>({
|
||||||
storagePrefix: 'privateKey',
|
storagePrefix: 'privateKey',
|
||||||
extraManifestFieldsSchema: keyMetadataSchema,
|
extraManifestFieldsSchema: keyMetadataSchema,
|
||||||
parseValue: (value) => value,
|
parseValue: (value) => value,
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import {
|
|
||||||
RnRussh,
|
|
||||||
type SshConnection,
|
|
||||||
type SshShell,
|
|
||||||
} from '@fressh/react-native-uniffi-russh';
|
|
||||||
|
|
||||||
// Simple in-memory registry owned by JS to track active handles.
|
|
||||||
// Keyed by `${connectionId}:${channelId}`.
|
|
||||||
|
|
||||||
export type SessionKey = string;
|
|
||||||
|
|
||||||
export type StoredSession = {
|
|
||||||
connection: SshConnection;
|
|
||||||
shell: SshShell;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sessions = new Map<SessionKey, StoredSession>();
|
|
||||||
|
|
||||||
export function makeSessionKey(
|
|
||||||
connectionId: string,
|
|
||||||
channelId: number,
|
|
||||||
): SessionKey {
|
|
||||||
return `${connectionId}:${channelId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerSession(
|
|
||||||
connection: SshConnection,
|
|
||||||
shell: SshShell,
|
|
||||||
): SessionKey {
|
|
||||||
const key = makeSessionKey(connection.connectionId, shell.channelId);
|
|
||||||
sessions.set(key, { connection, shell });
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSession(
|
|
||||||
connectionId: string,
|
|
||||||
channelId: number,
|
|
||||||
): StoredSession | undefined {
|
|
||||||
return sessions.get(makeSessionKey(connectionId, channelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeSession(connectionId: string, channelId: number): void {
|
|
||||||
sessions.delete(makeSessionKey(connectionId, channelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listSessions(): StoredSession[] {
|
|
||||||
return Array.from(sessions.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy list view expected shape
|
|
||||||
export type ShellWithConnection = StoredSession['shell'] & {
|
|
||||||
connection: SshConnection;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function listConnectionsWithShells(): (SshConnection & {
|
|
||||||
shells: StoredSession['shell'][];
|
|
||||||
})[] {
|
|
||||||
// Group shells by connection
|
|
||||||
const byConn = new Map<string, { conn: SshConnection; shells: SshShell[] }>();
|
|
||||||
for (const { connection, shell } of sessions.values()) {
|
|
||||||
const g = byConn.get(connection.connectionId) ?? {
|
|
||||||
conn: connection,
|
|
||||||
shells: [],
|
|
||||||
};
|
|
||||||
g.shells.push(shell);
|
|
||||||
byConn.set(connection.connectionId, g);
|
|
||||||
}
|
|
||||||
return Array.from(byConn.values()).map(({ conn, shells }) => ({
|
|
||||||
...conn,
|
|
||||||
shells,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience helpers for flows
|
|
||||||
export async function connectAndStart(
|
|
||||||
details: Parameters<typeof RnRussh.connect>[0],
|
|
||||||
) {
|
|
||||||
const conn = await RnRussh.connect(details);
|
|
||||||
const shell = await conn.startShell({ pty: 'Xterm' });
|
|
||||||
registerSession(conn, shell);
|
|
||||||
return { conn, shell };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeShell(connectionId: string, channelId: number) {
|
|
||||||
const sess = getSession(connectionId, channelId);
|
|
||||||
if (!sess) return;
|
|
||||||
await sess.shell.close();
|
|
||||||
removeSession(connectionId, channelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnectConnection(connectionId: string) {
|
|
||||||
const remaining = Array.from(sessions.entries()).filter(
|
|
||||||
([, v]) => v.connection.connectionId === connectionId,
|
|
||||||
);
|
|
||||||
for (const [key, sess] of remaining) {
|
|
||||||
try {
|
|
||||||
await sess.shell.close();
|
|
||||||
} catch {}
|
|
||||||
sessions.delete(key);
|
|
||||||
}
|
|
||||||
// Find one connection handle for this id to disconnect
|
|
||||||
const conn = remaining[0]?.[1].connection;
|
|
||||||
if (conn) {
|
|
||||||
try {
|
|
||||||
await conn.disconnect();
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
86
apps/mobile/src/lib/ssh-store.ts
Normal file
86
apps/mobile/src/lib/ssh-store.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
type SshConnection,
|
||||||
|
type SshShell,
|
||||||
|
type SshConnectionStatus,
|
||||||
|
} from '@fressh/react-native-uniffi-russh';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type SessionKey = string;
|
||||||
|
export const makeSessionKey = (connectionId: string, channelId: number) =>
|
||||||
|
`${connectionId}:${channelId}` as const;
|
||||||
|
|
||||||
|
export type SessionStatus = 'connecting' | 'connected' | 'disconnected';
|
||||||
|
|
||||||
|
export interface StoredSession {
|
||||||
|
connection: SshConnection;
|
||||||
|
shell: SshShell;
|
||||||
|
status: SessionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SshStoreState {
|
||||||
|
sessions: Record<SessionKey, StoredSession>;
|
||||||
|
addSession: (conn: SshConnection, shell: SshShell) => SessionKey;
|
||||||
|
removeSession: (key: SessionKey) => void;
|
||||||
|
setStatus: (key: SessionKey, status: SessionStatus) => void;
|
||||||
|
getByKey: (key: SessionKey) => StoredSession | undefined;
|
||||||
|
listConnectionsWithShells: () => (SshConnection & { shells: SshShell[] })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSshStore = create<SshStoreState>((set, get) => ({
|
||||||
|
sessions: {},
|
||||||
|
addSession: (conn, shell) => {
|
||||||
|
const key = makeSessionKey(conn.connectionId, shell.channelId);
|
||||||
|
set((s) => ({
|
||||||
|
sessions: {
|
||||||
|
...s.sessions,
|
||||||
|
[key]: { connection: conn, shell, status: 'connected' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
removeSession: (key) => {
|
||||||
|
set((s) => {
|
||||||
|
const { [key]: _omit, ...rest } = s.sessions;
|
||||||
|
return { sessions: rest };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setStatus: (key, status) => {
|
||||||
|
set((s) =>
|
||||||
|
s.sessions[key]
|
||||||
|
? { sessions: { ...s.sessions, [key]: { ...s.sessions[key], status } } }
|
||||||
|
: s,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getByKey: (key) => get().sessions[key],
|
||||||
|
listConnectionsWithShells: () => {
|
||||||
|
const byConn = new Map<
|
||||||
|
string,
|
||||||
|
{ conn: SshConnection; shells: SshShell[] }
|
||||||
|
>();
|
||||||
|
for (const { connection, shell } of Object.values(get().sessions)) {
|
||||||
|
const g = byConn.get(connection.connectionId) ?? {
|
||||||
|
conn: connection,
|
||||||
|
shells: [],
|
||||||
|
};
|
||||||
|
g.shells.push(shell);
|
||||||
|
byConn.set(connection.connectionId, g);
|
||||||
|
}
|
||||||
|
return Array.from(byConn.values()).map(({ conn, shells }) => ({
|
||||||
|
...conn,
|
||||||
|
shells,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function toSessionStatus(status: SshConnectionStatus): SessionStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'shellConnecting':
|
||||||
|
return 'connecting';
|
||||||
|
case 'shellConnected':
|
||||||
|
return 'connected';
|
||||||
|
case 'shellDisconnected':
|
||||||
|
return 'disconnected';
|
||||||
|
default:
|
||||||
|
return 'connected';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { preferences } from './preferences';
|
import { preferences } from './preferences';
|
||||||
|
|
||||||
export type AppTheme = {
|
export interface AppTheme {
|
||||||
colors: {
|
colors: {
|
||||||
background: string;
|
background: string;
|
||||||
surface: string;
|
surface: string;
|
||||||
@@ -20,7 +20,7 @@ export type AppTheme = {
|
|||||||
shadow: string;
|
shadow: string;
|
||||||
primaryDisabled: string;
|
primaryDisabled: string;
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export const darkTheme: AppTheme = {
|
export const darkTheme: AppTheme = {
|
||||||
colors: {
|
colors: {
|
||||||
@@ -70,11 +70,11 @@ export const themes: Record<ThemeName, AppTheme> = {
|
|||||||
light: lightTheme,
|
light: lightTheme,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThemeContextValue = {
|
interface ThemeContextValue {
|
||||||
theme: AppTheme;
|
theme: AppTheme;
|
||||||
themeName: ThemeName;
|
themeName: ThemeName;
|
||||||
setThemeName: (name: ThemeName) => void;
|
setThemeName: (name: ThemeName) => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
const ThemeContext = React.createContext<ThemeContextValue | undefined>(
|
const ThemeContext = React.createContext<ThemeContextValue | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -93,21 +93,17 @@ export function ThemeProvider(props: { children: React.ReactNode }) {
|
|||||||
[theme, themeName, setThemeName],
|
[theme, themeName, setThemeName],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return <ThemeContext value={value}>{props.children}</ThemeContext>;
|
||||||
<ThemeContext.Provider value={value}>
|
|
||||||
{props.children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const ctx = React.useContext(ThemeContext);
|
const ctx = React.use(ThemeContext);
|
||||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||||
return ctx.theme;
|
return ctx.theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useThemeControls() {
|
export function useThemeControls() {
|
||||||
const ctx = React.useContext(ThemeContext);
|
const ctx = React.use(ThemeContext);
|
||||||
if (!ctx)
|
if (!ctx)
|
||||||
throw new Error('useThemeControls must be used within ThemeProvider');
|
throw new Error('useThemeControls must be used within ThemeProvider');
|
||||||
const { themeName, setThemeName } = ctx;
|
const { themeName, setThemeName } = ctx;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const AbortSignalTimeout = (timeout: number) => {
|
|||||||
// AbortSignal.timeout is not available as of expo 54
|
// AbortSignal.timeout is not available as of expo 54
|
||||||
// TypeError: AbortSignal.timeout is not a function (it is undefined)
|
// TypeError: AbortSignal.timeout is not a function (it is undefined)
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setTimeout(() => controller.abort(), timeout);
|
setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, timeout);
|
||||||
return controller.signal;
|
return controller.signal;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html style="margin: 0; padding: 0; width: 100vw; height: 100vh">
|
<html style="margin: 0; padding: 0; width: 100%; height: 100%">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
|
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; width: 100vw; height: 100vh">
|
<body style="margin: 0; padding: 0; width: 100%; height: 100%">
|
||||||
<div
|
<div
|
||||||
id="terminal"
|
id="terminal"
|
||||||
style="margin: 0; padding: 0; width: 100%; height: 100%"
|
style="margin: 0; padding: 0; width: 100%; height: 100%"
|
||||||
|
|||||||
614
pnpm-lock.yaml
generated
614
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user