passing lint

This commit is contained in:
EthanShoeDev
2025-09-19 22:31:46 -04:00
parent dd0bb7636d
commit fc681f942d
14 changed files with 477 additions and 586 deletions

View File

@@ -0,0 +1,4 @@
.turbo
lib/
node_modules/
rust/

View File

@@ -4,15 +4,12 @@ Uniffi bindings for russh
## Installation
```sh
npm install react-native-uniffi-russh
```
## Usage
```js
import { multiply } from 'react-native-uniffi-russh';
@@ -21,7 +18,6 @@ import { multiply } from 'react-native-uniffi-russh';
const result = multiply(3, 7);
```
## Contributing
- [Development workflow](CONTRIBUTING.md#development-workflow)

View File

@@ -21,6 +21,6 @@ export default defineConfig([
},
},
{
ignores: ['node_modules/', 'lib/', 'src/generated/'],
ignores: ['node_modules/', 'lib/', 'src/generated/', 'eslint.config.mjs'],
},
]);

View File

@@ -39,8 +39,8 @@
"typecheck": "tsc",
"build:ios": "ubrn build ios --and-generate --release",
"build:android": "ubrn build android --and-generate --release",
"build:native": "tsx scripts/native-build.ts",
"build:bob": "bob build",
"build": "if [ \"$(uname)\" = \"Darwin\" ]; then turbo build:ios; else turbo build:android; fi",
"lint:rust": "cd rust/uniffi-russh && just lint",
"test": "jest",
"release": "release-it --only-version"

View File

@@ -0,0 +1,23 @@
import * as child from 'child_process';
import * as os from 'os';
const targetOptions = ['ios', 'android'] as const;
type Target = (typeof targetOptions)[number];
const envTarget = process.env.MOBILE_TARGET as Target | undefined;
if (envTarget && !targetOptions.includes(envTarget))
throw new Error(`Invalid target: ${envTarget}`);
const target =
envTarget ??
(() => {
const uname = os.platform();
if (uname === 'darwin') return 'ios';
return 'android';
})();
console.log(`Building for ${target}`);
child.execSync(`turbo run build:${target} --ui stream`, {
stdio: 'inherit',
});

View File

@@ -2,47 +2,50 @@
* We cannot make the generated code match this API exactly because uniffi
* - Doesn't support ts literals for rust enums
* - Doesn't support passing a js object with methods and properties to or from rust.
*
*
* The second issue is much harder to get around than the first.
* In practice it means that if you want to pass an object with callbacks and props to rust, it need to be in seperate args.
* If you want to pass an object with callbacks and props from rust to js (like ssh handles), you need to instead only pass an object with callbacks
* just make one of the callbacks a sync info() callback.
*
*
* Then in this api wrapper we can smooth over those rough edges.
* See: - https://jhugman.github.io/uniffi-bindgen-react-native/idioms/callback-interfaces.html
*/
import * as GeneratedRussh from './index';
// #region Ideal API
// ─────────────────────────────────────────────────────────────────────────────
// Core types
// ─────────────────────────────────────────────────────────────────────────────
export type PtyType =
| 'Vanilla' | 'Vt100' | 'Vt102' | 'Vt220' | 'Ansi' | 'Xterm' | 'Xterm256';
export type TerminalType =
| 'Vanilla'
| 'Vt100'
| 'Vt102'
| 'Vt220'
| 'Ansi'
| 'Xterm'
| 'Xterm256';
export type ConnectionDetails = {
host: string;
port: number;
username: string;
security:
| { type: 'password'; password: string }
| { type: 'key'; privateKey: string };
| { type: 'password'; password: string }
| { type: 'key'; privateKey: string };
};
/**
* This status is only to provide updates for discrete events
* during the connect() promise.
*
*
* It is no longer relevant after the connect() promise is resolved.
*/
export type SshConnectionProgress =
| 'tcpConnected' // TCP established, starting SSH handshake
| 'sshHandshake' // SSH protocol negotiation complete
| 'tcpConnected' // TCP established, starting SSH handshake
| 'sshHandshake'; // SSH protocol negotiation complete
export type ConnectOptions = ConnectionDetails & {
onConnectionProgress?: (status: SshConnectionProgress) => void;
@@ -51,31 +54,33 @@ export type ConnectOptions = ConnectionDetails & {
};
export type StartShellOptions = {
pty: PtyType;
onClosed?: (shellId: string) => void;
term: TerminalType;
terminalMode?: GeneratedRussh.TerminalMode[];
terminalPixelSize?: GeneratedRussh.TerminalPixelSize;
terminalSize?: GeneratedRussh.TerminalSize;
onClosed?: (shellId: number) => void;
abortSignal?: AbortSignal;
};
export type StreamKind = 'stdout' | 'stderr';
export type TerminalChunk = {
/** Monotonic sequence number from the shell start (Rust u64; JS uses number). */
seq: number;
seq: bigint;
/** Milliseconds since UNIX epoch (double). */
tMs: number;
stream: StreamKind;
bytes: Uint8Array;
bytes: ArrayBuffer;
};
export type DropNotice = { kind: 'dropped'; fromSeq: number; toSeq: number };
export type DropNotice = { kind: 'dropped'; fromSeq: bigint; toSeq: bigint };
export type ListenerEvent = TerminalChunk | DropNotice;
export type Cursor =
| { mode: 'head' } // earliest available in ring
| { mode: 'tailBytes'; bytes: number } // last N bytes (best-effort)
| { mode: 'seq'; seq: number } // from a given sequence
| { mode: 'time'; tMs: number } // from timestamp
| { mode: 'live' }; // no replay, live only
| { mode: 'head' } // earliest available in ring
| { mode: 'tailBytes'; bytes: bigint } // last N bytes (best-effort)
| { mode: 'seq'; seq: bigint } // from a given sequence
| { mode: 'time'; tMs: number } // from timestamp
| { mode: 'live' }; // no replay, live only
export type ListenerOptions = {
cursor: Cursor;
@@ -83,30 +88,27 @@ export type ListenerOptions = {
coalesceMs?: number;
};
export type BufferStats = {
ringBytes: number; // configured capacity
usedBytes: number; // current usage
chunks: number; // chunks kept
headSeq: number; // oldest seq retained
tailSeq: number; // newest seq retained
droppedBytesTotal: number; // cumulative eviction
};
export type BufferReadResult = {
chunks: TerminalChunk[];
nextSeq: number;
dropped?: { fromSeq: number; toSeq: number };
nextSeq: bigint;
dropped?: { fromSeq: bigint; toSeq: bigint };
};
// ─────────────────────────────────────────────────────────────────────────────
// Handles
// ─────────────────────────────────────────────────────────────────────────────
type ProgressTimings = {
tcpEstablishedAtMs: number;
sshHandshakeAtMs: number;
};
export type SshConnection = {
readonly connectionId: string;
readonly createdAtMs: number;
readonly tcpEstablishedAtMs: number;
readonly connectedAtMs: number;
readonly connectionDetails: ConnectionDetails;
readonly progressTimings: ProgressTimings;
startShell: (opts: StartShellOptions) => Promise<SshShell>;
disconnect: (opts?: { signal?: AbortSignal }) => Promise<void>;
@@ -115,20 +117,26 @@ export type SshConnection = {
export type SshShell = {
readonly channelId: number;
readonly createdAtMs: number;
readonly pty: PtyType;
readonly pty: TerminalType;
readonly connectionId: string;
// I/O
sendData: (data: ArrayBuffer, opts?: { signal?: AbortSignal }) => Promise<void>;
sendData: (
data: ArrayBuffer,
opts?: { signal?: AbortSignal }
) => Promise<void>;
close: (opts?: { signal?: AbortSignal }) => Promise<void>;
// Buffer policy & stats
setBufferPolicy: (policy: { ringBytes?: number; coalesceMs?: number }) => Promise<void>;
bufferStats: () => Promise<BufferStats>;
currentSeq: () => Promise<number>;
// setBufferPolicy: (policy: {
// ringBytes?: number;
// coalesceMs?: number;
// }) => Promise<void>;
bufferStats: () => GeneratedRussh.BufferStats;
currentSeq: () => number;
// Replay + live
readBuffer: (cursor: Cursor, maxBytes?: number) => Promise<BufferReadResult>;
readBuffer: (cursor: Cursor, maxBytes?: bigint) => BufferReadResult;
addListener: (
cb: (ev: ListenerEvent) => void,
opts: ListenerOptions
@@ -146,41 +154,55 @@ type RusshApi = {
// #region Wrapper to match the ideal API
const ptyTypeLiteralToEnum = {
Vanilla: GeneratedRussh.PtyType.Vanilla,
Vt100: GeneratedRussh.PtyType.Vt100,
Vt102: GeneratedRussh.PtyType.Vt102,
Vt220: GeneratedRussh.PtyType.Vt220,
Ansi: GeneratedRussh.PtyType.Ansi,
Xterm: GeneratedRussh.PtyType.Xterm,
Xterm256: GeneratedRussh.PtyType.Xterm256,
} as const satisfies Record<string, GeneratedRussh.PtyType>;
const terminalTypeLiteralToEnum = {
Vanilla: GeneratedRussh.TerminalType.Vanilla,
Vt100: GeneratedRussh.TerminalType.Vt100,
Vt102: GeneratedRussh.TerminalType.Vt102,
Vt220: GeneratedRussh.TerminalType.Vt220,
Ansi: GeneratedRussh.TerminalType.Ansi,
Xterm: GeneratedRussh.TerminalType.Xterm,
Xterm256: GeneratedRussh.TerminalType.Xterm256,
} as const satisfies Record<string, GeneratedRussh.TerminalType>;
const ptyEnumToLiteral: Record<GeneratedRussh.PtyType, PtyType> = {
[GeneratedRussh.PtyType.Vanilla]: 'Vanilla',
[GeneratedRussh.PtyType.Vt100]: 'Vt100',
[GeneratedRussh.PtyType.Vt102]: 'Vt102',
[GeneratedRussh.PtyType.Vt220]: 'Vt220',
[GeneratedRussh.PtyType.Ansi]: 'Ansi',
[GeneratedRussh.PtyType.Xterm]: 'Xterm',
[GeneratedRussh.PtyType.Xterm256]: 'Xterm256',
const terminalTypeEnumToLiteral: Record<
GeneratedRussh.TerminalType,
TerminalType
> = {
[GeneratedRussh.TerminalType.Vanilla]: 'Vanilla',
[GeneratedRussh.TerminalType.Vt100]: 'Vt100',
[GeneratedRussh.TerminalType.Vt102]: 'Vt102',
[GeneratedRussh.TerminalType.Vt220]: 'Vt220',
[GeneratedRussh.TerminalType.Ansi]: 'Ansi',
[GeneratedRussh.TerminalType.Xterm]: 'Xterm',
[GeneratedRussh.TerminalType.Xterm256]: 'Xterm256',
};
const sshConnStatusEnumToLiteral = {
[GeneratedRussh.SshConnectionStatus.TcpConnected]: 'tcpConnected',
[GeneratedRussh.SshConnectionStatus.SshHandshake]: 'sshHandshake',
} as const satisfies Record<GeneratedRussh.SshConnectionStatus, SshConnectionProgress>;
const sshConnProgressEnumToLiteral = {
[GeneratedRussh.SshConnectionProgressEvent.TcpConnected]: 'tcpConnected',
[GeneratedRussh.SshConnectionProgressEvent.SshHandshake]: 'sshHandshake',
} as const satisfies Record<
GeneratedRussh.SshConnectionProgressEvent,
SshConnectionProgress
>;
const streamEnumToLiteral = {
[GeneratedRussh.StreamKind.Stdout]: 'stdout',
[GeneratedRussh.StreamKind.Stderr]: 'stderr',
} as const satisfies Record<GeneratedRussh.StreamKind, StreamKind>;
function generatedConnDetailsToIdeal(details: GeneratedRussh.ConnectionDetails): ConnectionDetails {
const security: ConnectionDetails['security'] = details.security instanceof GeneratedRussh.Security.Password
? { type: 'password', password: details.security.inner.password }
: { type: 'key', privateKey: details.security.inner.keyId };
return { host: details.host, port: details.port, username: details.username, security };
function generatedConnDetailsToIdeal(
details: GeneratedRussh.ConnectionDetails
): ConnectionDetails {
const security: ConnectionDetails['security'] =
details.security instanceof GeneratedRussh.Security.Password
? { type: 'password', password: details.security.inner.password }
: { type: 'key', privateKey: details.security.inner.privateKeyContent };
return {
host: details.host,
port: details.port,
username: details.username,
security,
};
}
function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor {
@@ -188,9 +210,11 @@ function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor {
case 'head':
return new GeneratedRussh.Cursor.Head();
case 'tailBytes':
return new GeneratedRussh.Cursor.TailBytes({ bytes: BigInt(cursor.bytes) });
return new GeneratedRussh.Cursor.TailBytes({
bytes: cursor.bytes,
});
case 'seq':
return new GeneratedRussh.Cursor.Seq({ seq: BigInt(cursor.seq) });
return new GeneratedRussh.Cursor.Seq({ seq: cursor.seq });
case 'time':
return new GeneratedRussh.Cursor.TimeMs({ tMs: cursor.tMs });
case 'live':
@@ -200,38 +224,24 @@ function cursorToGenerated(cursor: Cursor): GeneratedRussh.Cursor {
function toTerminalChunk(ch: GeneratedRussh.TerminalChunk): TerminalChunk {
return {
seq: Number(ch.seq),
seq: ch.seq,
tMs: ch.tMs,
stream: streamEnumToLiteral[ch.stream],
bytes: new Uint8Array(ch.bytes as any),
bytes: ch.bytes,
};
}
function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell {
const info = shell.info();
function wrapShellSession(
shell: GeneratedRussh.ShellSessionInterface
): SshShell {
const info = shell.getInfo();
const setBufferPolicy: SshShell['setBufferPolicy'] = async (policy) => {
await shell.setBufferPolicy(policy.ringBytes != null ? BigInt(policy.ringBytes) : undefined, policy.coalesceMs);
};
const bufferStats: SshShell['bufferStats'] = async () => {
const s = shell.bufferStats();
return {
ringBytes: Number(s.ringBytes),
usedBytes: Number(s.usedBytes),
chunks: Number(s.chunks),
headSeq: Number(s.headSeq),
tailSeq: Number(s.tailSeq),
droppedBytesTotal: Number(s.droppedBytesTotal),
};
};
const readBuffer: SshShell['readBuffer'] = async (cursor, maxBytes) => {
const res = shell.readBuffer(cursorToGenerated(cursor), maxBytes != null ? BigInt(maxBytes) : undefined);
const readBuffer: SshShell['readBuffer'] = (cursor, maxBytes) => {
const res = shell.readBuffer(cursorToGenerated(cursor), maxBytes);
return {
chunks: res.chunks.map(toTerminalChunk),
nextSeq: Number(res.nextSeq),
dropped: res.dropped ? { fromSeq: Number(res.dropped.fromSeq), toSeq: Number(res.dropped.toSeq) } : undefined,
nextSeq: res.nextSeq,
dropped: res.dropped,
} satisfies BufferReadResult;
};
@@ -241,87 +251,128 @@ function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell
if (ev instanceof GeneratedRussh.ShellEvent.Chunk) {
cb(toTerminalChunk(ev.inner[0]!));
} else if (ev instanceof GeneratedRussh.ShellEvent.Dropped) {
cb({ kind: 'dropped', fromSeq: Number(ev.inner.fromSeq), toSeq: Number(ev.inner.toSeq) });
cb({
kind: 'dropped',
fromSeq: ev.inner.fromSeq,
toSeq: ev.inner.toSeq,
});
}
}
},
} satisfies GeneratedRussh.ShellListener;
try {
const id = shell.addListener(listener, { cursor: cursorToGenerated(opts.cursor), coalesceMs: opts.coalesceMs });
const id = shell.addListener(listener, {
cursor: cursorToGenerated(opts.cursor),
coalesceMs: opts.coalesceMs,
});
if (id === 0n) {
throw new Error('Failed to attach shell listener (id=0)');
}
return BigInt(id);
return id;
} catch (e) {
throw new Error(`addListener failed: ${String((e as any)?.message ?? e)}`);
throw new Error(
`addListener failed: ${String((e as any)?.message ?? e)}`
);
}
};
return {
channelId: info.channelId,
createdAtMs: info.createdAtMs,
pty: ptyEnumToLiteral[info.pty],
pty: terminalTypeEnumToLiteral[info.term],
connectionId: info.connectionId,
sendData: (data, o) => shell.sendData(data, o?.signal ? { signal: o.signal } : undefined),
sendData: (data, o) =>
shell.sendData(data, o?.signal ? { signal: o.signal } : undefined),
close: (o) => shell.close(o?.signal ? { signal: o.signal } : undefined),
setBufferPolicy,
bufferStats,
currentSeq: async () => Number(shell.currentSeq()),
// setBufferPolicy,
bufferStats: shell.bufferStats,
currentSeq: () => Number(shell.currentSeq()),
readBuffer,
addListener,
removeListener: (id) => shell.removeListener(id),
};
}
function wrapConnection(conn: GeneratedRussh.SshConnectionInterface): SshConnection {
const inf = conn.info();
function wrapConnection(
conn: GeneratedRussh.SshConnectionInterface
): SshConnection {
const info = conn.getInfo();
return {
connectionId: inf.connectionId,
connectionDetails: generatedConnDetailsToIdeal(inf.connectionDetails),
createdAtMs: inf.createdAtMs,
tcpEstablishedAtMs: inf.tcpEstablishedAtMs,
connectionId: info.connectionId,
connectionDetails: generatedConnDetailsToIdeal(info.connectionDetails),
createdAtMs: info.createdAtMs,
connectedAtMs: info.connectedAtMs,
progressTimings: {
tcpEstablishedAtMs: info.progressTimings.tcpEstablishedAtMs,
sshHandshakeAtMs: info.progressTimings.sshHandshakeAtMs,
},
startShell: async (params) => {
const shell = await conn.startShell(
{
pty: ptyTypeLiteralToEnum[params.pty],
onStatusChange: params.onStatusChange
? { onChange: (statusEnum) => params.onStatusChange!(sshConnStatusEnumToLiteral[statusEnum]) }
term: terminalTypeLiteralToEnum[params.term],
onClosedCallback: params.onClosed
? {
onChange: (channelId) => params.onClosed!(channelId),
}
: undefined,
terminalMode: params.terminalMode,
terminalPixelSize: params.terminalPixelSize,
terminalSize: params.terminalSize,
},
params.abortSignal ? { signal: params.abortSignal } : undefined,
params.abortSignal ? { signal: params.abortSignal } : undefined
);
return wrapShellSession(shell);
},
disconnect: (opts) => conn.disconnect(opts?.signal ? { signal: opts.signal } : undefined),
disconnect: (opts) =>
conn.disconnect(opts?.signal ? { signal: opts.signal } : undefined),
};
}
async function connect(options: ConnectOptions): Promise<SshConnection> {
const security =
options.security.type === 'password'
? new GeneratedRussh.Security.Password({ password: options.security.password })
: new GeneratedRussh.Security.Key({ keyId: options.security.privateKey });
? new GeneratedRussh.Security.Password({
password: options.security.password,
})
: new GeneratedRussh.Security.Key({
privateKeyContent: options.security.privateKey,
});
const sshConnection = await GeneratedRussh.connect(
{
host: options.host,
port: options.port,
username: options.username,
security,
onStatusChange: options.onStatusChange ? {
onChange: (statusEnum) => options.onStatusChange!(sshConnStatusEnumToLiteral[statusEnum])
} : undefined,
connectionDetails: {
host: options.host,
port: options.port,
username: options.username,
security,
},
onConnectionProgressCallback: options.onConnectionProgress
? {
onChange: (statusEnum) =>
options.onConnectionProgress!(
sshConnProgressEnumToLiteral[statusEnum]
),
}
: undefined,
onDisconnectedCallback: options.onDisconnected
? {
onChange: (connectionId) => options.onDisconnected!(connectionId),
}
: undefined,
},
options.abortSignal ? { signal: options.abortSignal } : undefined,
options.abortSignal ? { signal: options.abortSignal } : undefined
);
return wrapConnection(sshConnection);
}
async function generateKeyPair(type: 'rsa' | 'ecdsa' | 'ed25519') {
const map = { rsa: GeneratedRussh.KeyType.Rsa, ecdsa: GeneratedRussh.KeyType.Ecdsa, ed25519: GeneratedRussh.KeyType.Ed25519 } as const;
const map = {
rsa: GeneratedRussh.KeyType.Rsa,
ecdsa: GeneratedRussh.KeyType.Ecdsa,
ed25519: GeneratedRussh.KeyType.Ed25519,
} as const;
return GeneratedRussh.generateKeyPair(map[type]);
}
// #endregion
export const RnRussh = {

View File

@@ -1,6 +1,6 @@
{
"extends": "./tsconfig",
"exclude": ["example", "lib"],
"exclude": ["example", "lib", "scripts"],
"compilerOptions": {
"noUnusedParameters": false,
"noUnusedLocals": false

View File

@@ -4,24 +4,47 @@
"tasks": {
// Default overrides
"lint": {
"dependsOn": ["fmt", "^build"],
"dependsOn": ["fmt", "^build", "build:bob"],
"with": ["typecheck", "//#lint:root", "lint:rust"],
},
"lint:check": {
"dependsOn": ["^build"],
"dependsOn": ["^build", "build:bob"],
"with": ["fmt:check", "typecheck", "//#lint:check:root", "lint:rust"],
},
"build:android": {
"outputs": ["dist/**"],
"dependsOn": ["^build", "build:bob"],
"build": {
"dependsOn": ["build:bob"],
},
"build:ios": {
"outputs": ["dist/**"],
"dependsOn": ["^build", "build:bob"],
"typecheck": {
"dependsOn": ["build:native"],
},
// Special tasks
"lint:rust": {},
"build:bob": {},
"build:bob": {
"dependsOn": ["build:native"],
"inputs": ["src/**"],
"outputs": ["lib/**"],
},
"build:native": {},
"build:android": {
"inputs": ["rust/**", "!rust/target"],
"outputs": [
"android/**",
"cpp/**",
"src/generated/**",
"src/index.ts",
"src/NativeReactNativeUniffi*.ts",
],
},
"build:ios": {
"inputs": ["rust/**", "!rust/target"],
"outputs": [
"ios/**",
"cpp/**",
"src/generated/**",
"src/index.ts",
"src/NativeReactNativeUniffi*.ts",
],
},
},
}