Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.bundle.js
45 changes: 39 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ npm install
npm run pod
npm run start
npm run macos
# for release builds
# for release builds (automatically bundles the server)
npm run macos-release
```

### Windows Development
**Note:** The Reactotron server is now embedded in the macOS app and starts automatically on port 9292. No need to run a separate server process!

### Windows Development

#### System Requirements

Expand Down Expand Up @@ -69,14 +71,26 @@ Both platforms use unified commands for native module development:

See [Making a TurboModule](./docs/Making-a-TurboModule.md) for detailed native development instructions.

### Server Bundle

If you modify the standalone server code (`standalone-server.js`), rebuild the bundle:

```sh
npm run bundle-server
```

The bundle is automatically generated during release builds (`npm run macos-release`).

## Enabling Reactotron in your app

> [!NOTE]
> We don't have a simple way to integrate the new Reactotron-macOS into your app yet, but that will be coming at some point. This assumes you've cloned down Reactotron-macOS.

1. From the root of Reactotron-macOS, start the standalone relay server:
`node -e "require('./standalone-server').startReactotronServer({ port: 9292 })"`
2. In your app, add the following to your app.tsx:
The Reactotron server is now embedded in the macOS app and starts automatically when you launch it. Simply:

1. Run the Reactotron macOS app (via `npm run macos` or the built .app)
2. The server will automatically start on port 9292
3. In your app, add the following to your app.tsx:

```tsx
if (__DEV__) {
Expand All @@ -92,7 +106,26 @@ if (__DEV__) {
}
```

3. Start your app and Reactotron-macOS. You should see logs appear.
4. Start your app and you should see logs appear in Reactotron.

### Running the Server Standalone (Optional)

If you need to run the server without the GUI (for CI/CD or headless environments), you can still run:

```sh
node -e "require('./standalone-server').startReactotronServer({ port: 9292 })"
```

### Server Implementation Details

The embedded server:

- Starts automatically when the app launches
- Stops automatically when the app quits
- Runs on port 9292 by default (configurable in `AppDelegate.mm`)
- Is bundled as a single file with all dependencies
- Requires Node.js to be installed on the system
- Supports nvm, asdf, fnm, and other Node version managers

## Get Help

Expand Down
4 changes: 4 additions & 0 deletions app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { TimelineItem } from "./types"
import { PortalHost } from "./components/Portal"
import { StateScreen } from "./screens/StateScreen"
import { AboutModal } from "./components/AboutModal"
import { useReactotronServer } from "./state/useReactotronServer"

if (__DEV__) {
// This is for debugging Reactotron with ... Reactotron!
Expand Down Expand Up @@ -107,6 +108,9 @@ function App(): React.JSX.Element {

useMenuItem(menuConfig)

// Start the bundled Reactotron server
useReactotronServer()

setTimeout(() => {
fetch("https://www.google.com")
.then((res) => res.text())
Expand Down
48 changes: 36 additions & 12 deletions app/components/Sidebar/SidebarMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Animated, View, ViewStyle, Pressable, TextStyle, Text } from "react-native"
import { themed, useTheme, useThemeName } from "../../theme/theme"
import { useGlobal } from "../../state/useGlobal"
import { manualReconnect } from "../../state/connectToServer"
import { Icon } from "../Icon"

const MENU_ITEMS = [
Expand All @@ -24,6 +25,8 @@ export const SidebarMenu = ({ progress, mounted, collapsedWidth }: SidebarMenuPr
const theme = useTheme()
const [themeName, setTheme] = useThemeName()
const [isConnected] = useGlobal("isConnected", false)
const [connectionStatus] = useGlobal<string>("connectionStatus", "Disconnected")
const [clientIds] = useGlobal<string[]>("clientIds", [])
const [error] = useGlobal("error", null)
const arch = (global as any)?.nativeFabricUIManager ? "Fabric" : "Paper"

Expand Down Expand Up @@ -92,7 +95,12 @@ export const SidebarMenu = ({ progress, mounted, collapsedWidth }: SidebarMenuPr
})}
</View>
<View>
<View style={[$menuItem(), $statusItemContainer]}>
<Pressable
style={({ pressed }) => [$menuItem(), $statusItemContainer, pressed && $menuItemPressed]}
onPress={manualReconnect}
accessibilityRole="button"
accessibilityLabel={`Connection status: ${connectionStatus}. Tap to retry.`}
>
<View style={[{ width: iconColumnWidth }, $iconColumn()]}>
<View
style={[
Expand All @@ -104,17 +112,22 @@ export const SidebarMenu = ({ progress, mounted, collapsedWidth }: SidebarMenuPr
</View>

{mounted && (
<Animated.Text
style={[$menuItemText(), { opacity: labelOpacity }]}
numberOfLines={1}
ellipsizeMode="clip"
accessibilityElementsHidden={!mounted}
importantForAccessibility={mounted ? "auto" : "no-hide-descendants"}
>
Connection
</Animated.Text>
<Animated.View style={[$connectionContainer, { opacity: labelOpacity }]}>
<Animated.Text
style={[$menuItemText(), $connectionStatusText()]}
numberOfLines={2}
ellipsizeMode="tail"
accessibilityElementsHidden={!mounted}
importantForAccessibility={mounted ? "auto" : "no-hide-descendants"}
>
{connectionStatus}
</Animated.Text>
{isConnected && clientIds.length === 0 && (
<Text style={[$menuItemText(), $helpText()]}>Port 9292</Text>
)}
</Animated.View>
)}
</View>
</Pressable>
<View style={[$menuItem(), $statusItemContainer]}>
<View style={[{ width: iconColumnWidth }, $iconColumn()]}>
<View
Expand Down Expand Up @@ -218,4 +231,15 @@ const $statusText = themed<TextStyle>(({ colors }) => ({
fontWeight: "600",
marginLeft: -4,
}))
const $statusItemContainer: ViewStyle = { cursor: "default", height: 32 }
const $connectionStatusText = themed<TextStyle>(() => ({
fontSize: 11,
lineHeight: 14,
}))
const $helpText = themed<TextStyle>(({ colors }) => ({
fontSize: 10,
lineHeight: 12,
color: colors.neutral,
marginTop: 2,
}))
const $connectionContainer: ViewStyle = { flex: 1 }
const $statusItemContainer: ViewStyle = { cursor: "pointer", minHeight: 32 }
8 changes: 8 additions & 0 deletions app/native/IRReactotronServer/IRReactotronServer.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Generally, this file can be left alone as-is.
// Make your changes in the IRReactotronServer.mm file.
#import <Foundation/Foundation.h>
#import <AppSpec/AppSpec.h>

@interface IRReactotronServer : NativeIRReactotronServerSpecBase <NativeIRReactotronServerSpec>
@end

25 changes: 25 additions & 0 deletions app/native/IRReactotronServer/IRReactotronServer.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#import "IRReactotronServer.h"

@implementation IRReactotronServer RCT_EXPORT_MODULE()

/**
* Returns the path to the bundled Reactotron server script.
*/
- (NSString *)getBundlePath {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: since metro doesn't touch the server bundle, unfortunately this needs to be a native module :(

NSURL *bundleURL = [[NSBundle mainBundle] URLForResource:@"standalone-server.bundle" withExtension:@"js"];
if (bundleURL) {
NSLog(@"✓ Found Reactotron server bundle at: %@", bundleURL.path);
return bundleURL.path;
}

NSLog(@"⚠️ Reactotron server bundle not found in main bundle");
return nil;
}

// Required by TurboModules.
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared<facebook::react::NativeIRReactotronServerSpecJSI>(params);
}

@end

8 changes: 8 additions & 0 deletions app/native/IRReactotronServer/NativeIRReactotronServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { TurboModule } from "react-native"
import { TurboModuleRegistry } from "react-native"

export interface Spec extends TurboModule {
getBundlePath(): string | null
}

export default TurboModuleRegistry.getEnforcing<Spec>("IRReactotronServer")
73 changes: 70 additions & 3 deletions app/native/IRRunShellCommand/IRRunShellCommand.mm
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

#import "IRRunShellCommand.h"
#import <objc/runtime.h>
#import <pwd.h>
#import <AppSpec/AppSpec.h>

@interface IRRunShellCommand ()

Expand Down Expand Up @@ -44,6 +46,23 @@ - (NSNumber *)appPID {
return [NSNumber numberWithInteger:[[NSProcessInfo processInfo] processIdentifier]];
}

/**
* Returns the user's default shell from system.
*/
- (NSString *)getUserShell {
// Get user's default shell from system
struct passwd *pw = getpwuid(getuid());
if (pw && pw->pw_shell) {
NSString *detectedShell = [NSString stringWithUTF8String:pw->pw_shell];
NSLog(@"✓ Detected user shell from passwd: %@", detectedShell);
return detectedShell;
}

// Fallback to zsh (macOS default)
NSLog(@"⚠️ Failed to detect user shell, falling back to /bin/zsh");
return @"/bin/zsh";
}

/**
* This method runs a command and returns the output as a string.
* It's async, so use it for long-running commands.
Expand All @@ -67,17 +86,65 @@ - (NSString *)runSync:(NSString *)command {
/*
* Executes a shell command asynchronously on a background queue.
* Captures both stdout and stderr streams, and emits events with their output and completion status.
*
* If options.shell is provided, wraps the command in a shell with optional shellArgs.
*/

- (void)runTaskWithCommand:(NSString *)command
args:(NSArray<NSString *> *)args
taskId:(NSString *)taskId {
options:(JS::NativeIRRunShellCommand::SpecRunTaskWithCommandOptions &)options {
NSString *taskId = options.taskId();
NSString *shell = options.shell();
NSArray<NSString *> *shellArgs = nil;
auto optionalShellArgs = options.shellArgs();
if (optionalShellArgs.has_value()) {
auto lazyVector = optionalShellArgs.value();
NSMutableArray<NSString *> *tempArray = [NSMutableArray array];
for (size_t i = 0; i < lazyVector.size(); i++) {
[tempArray addObject:lazyVector[i]];
}
shellArgs = [tempArray copy];
}

dispatch_async(
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@autoreleasepool {
NSTask *task = [NSTask new];
task.executableURL = [NSURL fileURLWithPath:command];
task.arguments = args;

// If shell is provided, wrap the command execution
if (shell && shell.length > 0) {
// Build the command string by joining command with args
NSMutableArray *commandParts = [NSMutableArray arrayWithObject:command];
[commandParts addObjectsFromArray:args];

// Properly escape and quote arguments
NSMutableArray *quotedParts = [NSMutableArray array];
for (NSString *part in commandParts) {
// Escape single quotes and wrap in single quotes
NSString *escaped = [part stringByReplacingOccurrencesOfString:@"'" withString:@"'\\''"];
[quotedParts addObject:[NSString stringWithFormat:@"'%@'", escaped]];
}
NSString *commandString = [quotedParts componentsJoinedByString:@" "];

// Set the shell as the executable
task.executableURL = [NSURL fileURLWithPath:shell];

// Combine shellArgs with -c and the command string
NSMutableArray *finalArgs = [NSMutableArray array];
if (shellArgs) {
[finalArgs addObjectsFromArray:shellArgs];
}
[finalArgs addObject:@"-c"];
[finalArgs addObject:commandString];

task.arguments = finalArgs;

NSLog(@"Running command with shell (%@): %@ %@", shell, [shellArgs componentsJoinedByString:@" "], commandString);
} else {
// Direct execution without shell
task.executableURL = [NSURL fileURLWithPath:command];
task.arguments = args;
}

[self.tasksLock lock];
self.runningTasks[taskId] = task;
Expand Down
14 changes: 13 additions & 1 deletion app/native/IRRunShellCommand/NativeIRRunShellCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,22 @@ export interface ShellCommandCompleteEvent {
export interface Spec extends TurboModule {
appPath(): string
appPID(): number
getUserShell(): string
runAsync(command: string): Promise<string>
runSync(command: string): string
runCommandOnShutdown(command: string): void
runTaskWithCommand(command: string, args: string[], taskId: string): void
runTaskWithCommand(
command: string,
args: string[],
options: {
/** Shell to execute the command with. */
shell?: string
/** Arguments to pass to the shell like -l and -i. */
shellArgs?: string[]
/** ID of the task. */
taskId: string
},
): void
getRunningTaskIds(): ReadonlyArray<string>
killTaskWithId(taskId: string): boolean
killAllTasks(): void
Expand Down
Loading