Skip to content

Commit

Permalink
🦴 Debugger integration
Browse files Browse the repository at this point in the history
  • Loading branch information
hyzyla committed May 11, 2024
1 parent ca54751 commit 0bd9b92
Show file tree
Hide file tree
Showing 17 changed files with 216 additions and 29 deletions.
Binary file added docs/images/debug-attach-ios-simulator.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/debug-breakpoints.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/debug-create-launch-json.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/debug-install-codelldb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/debug-launch-app.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/debug-update-launch-json.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions docs/wiki/debug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Debugging iOS application

To debug an iOS application extension, provide thin integration with the
[CodeLLDB](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb) extension, powered by
[LLDB](https://lldb.llvm.org/).

## Tutorial

1. Install the [CodeLLDB](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb) extension from the
Visual Studio Code marketplace.

![Install CodeLLDB](../images/debug-install-codelldb.png)

2. Create a `launch.json` configuration file in the `.vscode` directory of your project. The configuration file should
contain the following configuration:

```json
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "attach",
"name": "Attach to iOS Simulator",
"waitFor": true,
"program": "${command:sweetpad.debugger.getAppPath}"
}
]
}
```

![Create launch.json](../images/debug-create-launch-json.png)

![Update launch.json](../images/debug-update-launch-json.png)

> Do you notice the `${command:sweetpad.debugger.getAppPath}`? This is a command that will be executed before debugging
> starts and will return the path to the application that was recently built by SweetPad. That path is required by the
> CodeLLDB extension in order to attach to the running application. You can read more about the CodeLLDB debugger
> options in the [official documentation](https://github.com/vadimcn/codelldb/blob/master/MANUAL.md).
3. Start the iOS simulator and run the application using the SweetPad "Launch" command on the "Build" panel. Wait until
the application is launched on the simulator.

![Launch](../images/debug-launch-app.png)

4. Attach the debugger to the running application by clicking on the "Attach to iOS Simulator" configuration on the
Debug panel. It takes a few seconds to attach the debugger to the running application. If you see the "Call Stack"
panel with the list of threads and frames, then the debugger is successfully attached.

![Attach](../images/debug-attach-ios-simulator.png)

5. Now set breakpoints in your code and start debugging your application. Next time, you can just attach the debugger to
the running application without the previous steps.

![Breakpoints](../images/debug-breakpoints.png)
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"workspaceContains:**/*.xcodeproj",
"workspaceContains:**/project.yml",
"workspaceContains:**/Podfile",
"onLanguage:swift"
"onLanguage:swift",
"onDebug"
],
"main": "./out/extension.js",
"contributes": {
Expand Down Expand Up @@ -213,6 +214,11 @@
"command": "sweetpad.system.createIssue.noSchemes",
"title": "SweetPad: Create Issue on GitHub (No Schemes)",
"icon": "$(bug)"
},
{
"command": "sweetpad.debugger.getAppPath",
"title": "SweetPad: Get app path for debugging",
"icon": "$(file-code)"
}
],
"menus": {
Expand Down
2 changes: 2 additions & 0 deletions src/build/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export async function runOnDevice(
});

// Run app
context.updateSessionState("build.lastLaunchedAppPath", targetPath);

await terminal.execute({
command: "xcrun",
args: ["simctl", "launch", "--console-pty", "--terminate-running-process", simulator.udid, bundleIdentifier],
Expand Down
4 changes: 3 additions & 1 deletion src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ export async function selectXcodeWorkspace(): Promise<string> {
// No files, nothing to do
if (paths.length === 0) {
throw new ExtensionError("No xcode workspaces found", {
cwd: workspacePath,
context: {
cwd: workspacePath,
},
});
}

Expand Down
6 changes: 4 additions & 2 deletions src/common/cli/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function getSimulatorByUdid(udid: string) {
}
}
}
throw new ExtensionError("Simulator not found", { udid });
throw new ExtensionError("Simulator not found", { context: { udid } });
}

export type BuildSettingsOutput = BuildSettingOutput[];
Expand Down Expand Up @@ -155,7 +155,9 @@ export async function getXcodeProjectPath(): Promise<string> {
});
if (projects.length === 0) {
throw new ExtensionError("No xcode projects found", {
cwd: workspaceFolder,
context: {
cwd: workspaceFolder,
},
});
}
return projects[0];
Expand Down
58 changes: 42 additions & 16 deletions src/common/commands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import { ExtensionError, TaskError } from "./errors";
import { ErrorMessageAction, ExtensionError, TaskError } from "./errors";
import { commonLogger } from "./logger";
import { isFileExists } from "./files";
import { BuildTreeProvider } from "../build/tree";
Expand All @@ -14,11 +14,14 @@ type WorkspaceStateKey =
| "build.xcodeSimulator"
| "build.xcodeSdk";

type SessionStateKey = "build.lastLaunchedAppPath";

export class ExtensionContext {
private _context: vscode.ExtensionContext;
public _buildProvider: BuildTreeProvider;
public _simulatorsProvider: SimulatorsTreeProvider;
public _toolsProvider: ToolTreeProvider;
private _sessionState: Map<SessionStateKey, any> = new Map();

constructor(options: {
context: vscode.ExtensionContext;
Expand All @@ -44,13 +47,24 @@ export class ExtensionContext {
this._context.subscriptions.push(disposable);
}

registerCommand(command: string, callback: (context: CommandExecution, ...args: any[]) => Promise<void>) {
registerCommand(command: string, callback: (context: CommandExecution, ...args: any[]) => Promise<any>) {
return vscode.commands.registerCommand(command, (...args: any[]) => {
const execution = new CommandExecution(command, callback, this);
return execution.run(...args);
});
}

/**
* State local to the running instance of the extension. It is not persisted across sessions.
*/
updateSessionState(key: SessionStateKey, value: any | undefined) {
this._sessionState.set(key, value);
}

getSessionState<T = any>(key: SessionStateKey): T | undefined {
return this._sessionState.get(key);
}

updateWorkspaceState(key: WorkspaceStateKey, value: any | undefined) {
this._context.workspaceState.update(`sweetpad.${key}`, value);
}
Expand Down Expand Up @@ -117,23 +131,33 @@ export class CommandExecution {
async showErrorMessage(
message: string,
options?: {
withoutShowDetails: boolean;
actions?: ErrorMessageAction[];
}
): Promise<void> {
type Action = "Show details" | "Close";
const closeAction: ErrorMessageAction = {
label: "Close",
callback: () => {},
};
const showLogsAction: ErrorMessageAction = {
label: "Show logs",
callback: () => commonLogger.show(),
};

// Close is always should be last, if "actions" is not provided, then show "Show logs" action also

let actions = [closeAction];
actions.unshift(...(options?.actions ?? [showLogsAction]));

const actions: Action[] = options?.withoutShowDetails ? ["Close"] : ["Show details", "Close"];
const actionsLabels = actions.map((action) => action.label);

const finalMessage = `${message}`;
const action = await vscode.window.showErrorMessage<Action>(finalMessage, ...actions);

switch (action) {
case "Show details":
// Help user to find logs by showing the logs view
commonLogger.show();
break;
case "Close" || undefined:
break;
const action = await vscode.window.showErrorMessage(finalMessage, ...actionsLabels);

if (action) {
const callback = actions.find((a) => a.label === action)?.callback;
if (callback) {
callback();
}
}
}

Expand All @@ -149,12 +173,14 @@ export class CommandExecution {
// Handle default error
commonLogger.error(error.message, {
command: this.command,
errorContext: error.context,
errorContext: error.options?.context,
});
if (error instanceof TaskError) {
// do nothing
} else {
await this.showErrorMessage(`Sweetpad: ${error.message}`);
await this.showErrorMessage(`Sweetpad: ${error.message}`, {
actions: error.options?.actions,
});
}
} else {
// Handle unexpected error
Expand Down
26 changes: 18 additions & 8 deletions src/common/errors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
export class ExtensionError extends Error {
export type ErrorMessageAction = {
label: string;
callback: () => void;
};

export type ExtensionErrorOptions = {
actions?: ErrorMessageAction[];
context?: Record<string, unknown>;
};

export class ExtensionError extends Error {
options?: ExtensionErrorOptions;

constructor(message: string, context?: Record<string, unknown>) {
constructor(message: string, options?: ExtensionErrorOptions) {
super(message);
this.context = context;
this.options = options;
}
}

Expand All @@ -21,7 +31,7 @@ export class TaskError extends ExtensionError {
errorCode?: number;
}
) {
super(message, context);
super(message, { context });
}
}

Expand All @@ -31,9 +41,9 @@ export class TaskError extends ExtensionError {
export class ExecBaseError extends ExtensionError {
constructor(
message: string,
options: { errorMessage: string; stderr?: string; command: string; args: string[]; cwd?: string }
context: { errorMessage: string; stderr?: string; command: string; args: string[]; cwd?: string }
) {
super(message, options);
super(message, { context });
}
}

Expand All @@ -43,8 +53,8 @@ export class ExecBaseError extends ExtensionError {
export class ExecErrror extends ExecBaseError {
constructor(
message: string,
options: { command: string; args: string[]; cwd?: string; exitCode: number; stderr: string; errorMessage: string }
context: { command: string; args: string[]; cwd?: string; exitCode: number; stderr: string; errorMessage: string }
) {
super(message, options);
super(message, context);
}
}
10 changes: 9 additions & 1 deletion src/common/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type CommandOptions = {
command: string;
args?: string[];
pipes?: Command[];
setvbuf?: boolean;
env?: Record<string, string>;
};

Expand Down Expand Up @@ -81,7 +82,14 @@ export class TaskTerminalV2 implements vscode.Pseudoterminal, TaskTerminal {
}

private async createCommandLine(options: CommandOptions): Promise<string> {
const mainCommand = quote([options.command, ...(options.args ?? [])]);
let mainCommand = quote([options.command, ...(options.args ?? [])]);
if (options.setvbuf) {
const setvbufPath = path.join(this.context.extensionPath, "out/setvbuf_universal.so");
const setvbufExists = await isFileExists(setvbufPath);
if (setvbufExists) {
mainCommand = `DYLD_INSERT_LIBRARIES=${quote([setvbufPath])} DYLD_FORCE_FLAT_NAMESPACE=y ${mainCommand}`;
}
}

if (!options.pipes) {
return mainCommand;
Expand Down
20 changes: 20 additions & 0 deletions src/debugger/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { CommandExecution } from "../common/commands";
import { ExtensionError } from "../common/errors";
import * as vscode from "vscode";

const DEBUG_DOCUMENTATION_URL = "https://github.com/sweetpad-dev/sweetpad/blob/main/docs/wiki/debug.md";

export async function getAppPathCommand(execution: CommandExecution): Promise<string> {
const sessionPath = execution.context.getSessionState<string>("build.lastLaunchedAppPath");
if (!sessionPath) {
throw new ExtensionError(`No last launched app path found, please launch the app first using the extension`, {
actions: [
{
label: "Open documentation",
callback: () => vscode.env.openExternal(vscode.Uri.parse(DEBUG_DOCUMENTATION_URL)),
},
],
});
}
return sessionPath;
}
50 changes: 50 additions & 0 deletions src/debugger/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import vscode from "vscode";
import { ExtensionContext } from "../common/commands";

const ATTACH_CONFIG: vscode.DebugConfiguration = {
type: "lldb",
request: "attach",
name: "Attach to iOS Simulator (SweetPad)",
waitFor: true,
program: "${command:sweetpad.debugger.getAppPath}",
};

export class DebuggerConfigurationProvider implements vscode.DebugConfigurationProvider {
async provideDebugConfigurations(
folder: vscode.WorkspaceFolder | undefined,
token?: vscode.CancellationToken | undefined
): Promise<vscode.DebugConfiguration[]> {
return [ATTACH_CONFIG];
}

async resolveDebugConfiguration(
folder: vscode.WorkspaceFolder | undefined,
config: vscode.DebugConfiguration,
token?: vscode.CancellationToken | undefined
): Promise<vscode.DebugConfiguration> {
// currently doing nothing useful here, but leave it for future extension
return config;
}

async resolveDebugConfigurationWithSubstitutedVariables(
folder: vscode.WorkspaceFolder | undefined,
config: vscode.DebugConfiguration,
token?: vscode.CancellationToken | undefined
): Promise<vscode.DebugConfiguration> {
// currently doing nothing useful here, but leave it for future extension
return config;
}
}

export function registerDebugConfigurationProvider(context: ExtensionContext) {
vscode.debug.registerDebugConfigurationProvider(
"lldb",
new DebuggerConfigurationProvider(),
vscode.DebugConfigurationProviderTriggerKind.Initial
);
return vscode.debug.registerDebugConfigurationProvider(
"lldb",
new DebuggerConfigurationProvider(),
vscode.DebugConfigurationProviderTriggerKind.Dynamic
);
}
6 changes: 6 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { createIssueGenericCommand, createIssueNoSchemesCommand, resetSweetpadCa
import { XcodeBuildTaskProvider } from "./build/provider.js";
import { xcodgenGenerateCommand } from "./xcodegen/commands.js";
import { createXcodeGenWatcher } from "./xcodegen/watcher.js";
import { registerDebugConfigurationProvider } from "./debugger/provider.js";
import { getAppPathCommand } from "./debugger/commands.js";

export function activate(context: vscode.ExtensionContext) {
// Trees 🎄
Expand All @@ -48,6 +50,10 @@ export function activate(context: vscode.ExtensionContext) {

const buildTaskProvider = new XcodeBuildTaskProvider(_context);

// Debug
d(registerDebugConfigurationProvider(_context));
d(command("sweetpad.debugger.getAppPath", getAppPathCommand));

// Tasks
d(vscode.tasks.registerTaskProvider(buildTaskProvider.type, buildTaskProvider));

Expand Down

0 comments on commit 0bd9b92

Please sign in to comment.