├── .npmrc
├── .vscode
├── .gitignore
├── extensions.json
├── tasks.json
├── settings.json
└── launch.json
├── .gitattributes
├── .gitignore
├── .README
├── screenshot.png
├── file-listing.png
├── output-pane.png
├── download-button.png
├── run-quick-pick.png
├── device-quick-pick.png
├── run-context-menu.png
├── device-context-menu.png
├── device-connect-tree-item.png
└── device-context-menu-screenshot.png
├── resources
└── icons
│ ├── ev3dev-logo.png
│ ├── dark
│ ├── refresh.svg
│ ├── green-circle.svg
│ ├── red-circle.svg
│ ├── yellow-circle.svg
│ └── download.svg
│ └── light
│ ├── refresh.svg
│ ├── green-circle.svg
│ ├── red-circle.svg
│ ├── yellow-circle.svg
│ └── download.svg
├── .travis.yml
├── .vscodeignore
├── tslint.json
├── .editorconfig
├── .github
└── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── tsconfig.json
├── LICENSE.txt
├── src
├── debugServer.ts
├── dnssd.ts
├── utils.ts
├── brickd.ts
├── dnssd
│ ├── dnssd.ts
│ ├── bonjour.ts
│ └── avahi.ts
├── device.ts
└── extension.ts
├── README.md
├── CHANGELOG.md
└── package.json
/.npmrc:
--------------------------------------------------------------------------------
1 | optional = false
2 |
--------------------------------------------------------------------------------
/.vscode/.gitignore:
--------------------------------------------------------------------------------
1 | tags
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text eol=lf
2 | *.png binary
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | out
2 | node_modules
3 |
4 | # built extension packages
5 | *.vsix
6 |
--------------------------------------------------------------------------------
/.README/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/screenshot.png
--------------------------------------------------------------------------------
/.README/file-listing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/file-listing.png
--------------------------------------------------------------------------------
/.README/output-pane.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/output-pane.png
--------------------------------------------------------------------------------
/.README/download-button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/download-button.png
--------------------------------------------------------------------------------
/.README/run-quick-pick.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/run-quick-pick.png
--------------------------------------------------------------------------------
/.README/device-quick-pick.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/device-quick-pick.png
--------------------------------------------------------------------------------
/.README/run-context-menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/run-context-menu.png
--------------------------------------------------------------------------------
/.README/device-context-menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/device-context-menu.png
--------------------------------------------------------------------------------
/resources/icons/ev3dev-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/resources/icons/ev3dev-logo.png
--------------------------------------------------------------------------------
/.README/device-connect-tree-item.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/device-connect-tree-item.png
--------------------------------------------------------------------------------
/.README/device-context-menu-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/device-context-menu-screenshot.png
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 |
3 | os:
4 | - linux
5 |
6 | language: node_js
7 | node_js: lts/*
8 |
9 | install:
10 | - npm install
11 |
12 | script:
13 | - npm run vscode:prepublish
14 |
--------------------------------------------------------------------------------
/.vscodeignore:
--------------------------------------------------------------------------------
1 | .github/
2 | .README/
3 | .vscode/**
4 | .vscode-test/**
5 | node_modules/
6 | out/test/**
7 | src/**
8 | .gitattributes
9 | .gitignore
10 | vsc-extension-quickstart.md
11 | **/tsconfig.json
12 | **/tslint.json
13 | **/*.map
14 | **/*.ts
15 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | // See http://go.microsoft.com/fwlink/?LinkId=827846
3 | // for the documentation about the extensions.json format
4 | "recommendations": [
5 | "ms-vscode.vscode-typescript-tslint-plugin",
6 | "EditorConfig.EditorConfig"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-string-throw": true,
4 | "no-unused-expression": true,
5 | "no-duplicate-variable": true,
6 | "curly": true,
7 | "class-name": true,
8 | "semicolon": [
9 | true,
10 | "always"
11 | ],
12 | "triple-equals": true
13 | },
14 | "defaultSeverity": "warning"
15 | }
16 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | trim_trailing_whitespace = true
6 | insert_final_newline = true
7 |
8 | [*.{md,py,ts}]
9 | indent_size = 4
10 | indent_style = space
11 |
12 | [*.json]
13 | indent_size = 4
14 | indent_style = tab
15 |
16 | [package*.json]
17 | indent_size = 4
18 | indent_style = space
19 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | // See https://go.microsoft.com/fwlink/?LinkId=733558
2 | // for the documentation about the tasks.json format
3 | {
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "type": "npm",
8 | "script": "watch",
9 | "problemMatcher": "$tsc-watch",
10 | "isBackground": true,
11 | "presentation": {
12 | "reveal": "never"
13 | },
14 | "group": {
15 | "kind": "build",
16 | "isDefault": true
17 | }
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | // Place your settings in this file to overwrite default and user settings.
2 | {
3 | "files.exclude": {
4 | "out": false // set this to true to hide the "out" folder with the compiled JS files
5 | },
6 | "search.exclude": {
7 | "out": true // set this to false to include "out" folder in search results
8 | },
9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts
10 | "typescript.tsc.autoDetect": "off",
11 | "editor.formatOnSave": true,
12 | "[typescript]": {
13 | "editor.defaultFormatter": "vscode.typescript-language-features"
14 | },
15 | "[json]": {
16 | "editor.defaultFormatter": "vscode.json-language-features"
17 | },
18 | "[markdown]": {
19 | "editor.defaultFormatter": "vscode.markdown-language-features"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS and version: [e.g. Windows 10 1809, macOS 10.14]
28 |
29 | **Additional context**
30 | Add any other context about the problem here.
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "*": [
6 | "types/*"
7 | ]
8 | },
9 | "module": "commonjs",
10 | "target": "es6",
11 | "outDir": "out",
12 | "lib": [
13 | "es6"
14 | ],
15 | "sourceMap": true,
16 | "rootDir": "src",
17 | /* Strict Type-Checking Option */
18 | "strict": true, /* enable all strict type-checking options */
19 | /* Additional Checks */
20 | //"noUnusedLocals": true, /* Report errors on unused locals. */
21 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
22 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
23 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
24 | "esModuleInterop": true,
25 | },
26 | "exclude": [
27 | "node_modules",
28 | ".vscode-test"
29 | ]
30 | }
--------------------------------------------------------------------------------
/resources/icons/dark/refresh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/resources/icons/light/refresh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | // A launch configuration that compiles the extension and then opens it inside a new window
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | {
6 | "version": "0.2.0",
7 | "configurations": [{
8 | "name": "Run Extension",
9 | "type": "extensionHost",
10 | "request": "launch",
11 | "runtimeExecutable": "${execPath}",
12 | "args": [
13 | "--extensionDevelopmentPath=${workspaceFolder}"
14 | ],
15 | "outFiles": [
16 | "${workspaceFolder}/out/**/*.js"
17 | ],
18 | "preLaunchTask": "npm: watch"
19 | },
20 | {
21 | "name": "Extension Tests",
22 | "type": "extensionHost",
23 | "request": "launch",
24 | "runtimeExecutable": "${execPath}",
25 | "args": [
26 | "--extensionDevelopmentPath=${workspaceFolder}",
27 | "--extensionTestsPath=${workspaceFolder}/out/test"
28 | ],
29 | "outFiles": [
30 | "${workspaceFolder}/out/test/**/*.js"
31 | ],
32 | "preLaunchTask": "npm: watch"
33 | }
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017-2023 David Lechner
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/resources/icons/dark/green-circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
38 |
--------------------------------------------------------------------------------
/resources/icons/dark/red-circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
38 |
--------------------------------------------------------------------------------
/resources/icons/dark/yellow-circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
38 |
--------------------------------------------------------------------------------
/resources/icons/light/green-circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
38 |
--------------------------------------------------------------------------------
/resources/icons/light/red-circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
38 |
--------------------------------------------------------------------------------
/resources/icons/light/yellow-circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
38 |
--------------------------------------------------------------------------------
/resources/icons/dark/download.svg:
--------------------------------------------------------------------------------
1 |
2 |
54 |
--------------------------------------------------------------------------------
/resources/icons/light/download.svg:
--------------------------------------------------------------------------------
1 |
2 |
54 |
--------------------------------------------------------------------------------
/src/debugServer.ts:
--------------------------------------------------------------------------------
1 | import { DebugSession, Event, TerminatedEvent, Thread, ThreadEvent, StoppedEvent, ContinuedEvent, InitializedEvent } from 'vscode-debugadapter';
2 | import { DebugProtocol } from 'vscode-debugprotocol';
3 |
4 | /**
5 | * This interface should always match the schema found in the extension manifest.
6 | */
7 | export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArguments {
8 | /** An absolute path to the program to debug. */
9 | program: string;
10 | /** Download files before running. Default is true. */
11 | download?: boolean;
12 | /** Run in terminal instead of output pane. */
13 | interactiveTerminal: boolean;
14 | }
15 |
16 | const THREAD_ID = 0;
17 |
18 | export class Ev3devBrowserDebugSession extends DebugSession {
19 | protected initializeRequest(response: DebugProtocol.InitializeResponse,
20 | args: DebugProtocol.InitializeRequestArguments): void {
21 | if (response.body) {
22 | response.body.supportTerminateDebuggee = true;
23 | }
24 | this.sendResponse(response);
25 | }
26 |
27 | protected launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments): void {
28 | this.sendEvent(new Event('ev3devBrowser.debugger.launch', args));
29 | this.sendResponse(response);
30 | this.sendEvent(new InitializedEvent());
31 | }
32 |
33 | protected customRequest(command: string, response: DebugProtocol.Response, args: any): void {
34 | switch (command) {
35 | case 'ev3devBrowser.debugger.thread':
36 | this.sendEvent(new ThreadEvent(args, THREAD_ID));
37 | this.sendResponse(response);
38 | break;
39 | case 'ev3devBrowser.debugger.terminate':
40 | this.sendEvent(new TerminatedEvent());
41 | this.sendResponse(response);
42 | break;
43 | }
44 | }
45 |
46 | protected disconnectRequest(response: DebugProtocol.DisconnectResponse,
47 | args: DebugProtocol.DisconnectArguments): void {
48 | this.sendEvent(new Event('ev3devBrowser.debugger.stop', args));
49 | this.sendResponse(response);
50 | }
51 |
52 | protected threadsRequest(response: DebugProtocol.ThreadsResponse): void {
53 | response.body = {
54 | threads: [
55 | new Thread(THREAD_ID, 'thread')
56 | ]
57 | };
58 | this.sendResponse(response);
59 | }
60 |
61 | protected pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments): void {
62 | this.sendEvent(new Event('ev3devBrowser.debugger.interrupt', args));
63 | this.sendResponse(response);
64 | }
65 | }
66 |
67 | if (require.main === module) {
68 | DebugSession.run(Ev3devBrowserDebugSession);
69 | }
70 |
--------------------------------------------------------------------------------
/src/dnssd.ts:
--------------------------------------------------------------------------------
1 |
2 | import * as avahi from './dnssd/avahi';
3 | import * as dnssd from './dnssd/dnssd';
4 | import * as bonjour from './dnssd/bonjour';
5 |
6 | /**
7 | * Common interface used by dnssd implementations.
8 | */
9 | export interface Client {
10 | /**
11 | * Create a new browser object.
12 | */
13 | createBrowser(options: BrowseOptions): Promise;
14 |
15 | /**
16 | * Frees resources used by client and destroys any associated browsers, etc.
17 | */
18 | destroy(): void;
19 | }
20 |
21 | /**
22 | * Options for Dnssd.browse()
23 | */
24 | export interface BrowseOptions {
25 | /**
26 | * The service type to browse for, e.g. 'http'.
27 | */
28 | service: string;
29 |
30 | /**
31 | * The protocol transport to search for. Must be 'tcp' or 'udp'.
32 | * Default is 'tcp' if omitted.
33 | */
34 | transport?: 'tcp' | 'udp';
35 |
36 | /**
37 | * The IP protocol to search for. Must be 'IPv4' or 'IPv6'.
38 | * Default is 'IPv4' if omitted.
39 | */
40 | ipv?: 'IPv4' | 'IPv6';
41 | }
42 |
43 | /** Object for monitoring network service discovery events. */
44 | export interface Browser {
45 | /** Registers callback for service added events. */
46 | on(event: 'added', listener: (service: Service) => void): this;
47 | /** Registers callback for service removed events. */
48 | on(event: 'removed', listener: (service: Service) => void): this;
49 | /** Registers callback for error events. */
50 | on(event: 'error', listener: (err: Error) => void): this;
51 | /** Starts browsing. */
52 | start(): Promise;
53 | /** Stops browsing. */
54 | stop(): Promise;
55 | /** Frees all resources used by browser. */
56 | destroy(): void;
57 | }
58 |
59 | /**
60 | * Data type for txt record key/value pairs.
61 | */
62 | export type TxtRecords = { [key: string]: string };
63 |
64 | export interface Service {
65 | /**
66 | * The name of the service. Suitible for displaying to the user.
67 | */
68 | readonly name: string;
69 |
70 | /**
71 | * The service type.
72 | */
73 | readonly service: string;
74 |
75 | /**
76 | * The transport protocol.
77 | */
78 | readonly transport: 'tcp' | 'udp';
79 |
80 | /**
81 | * The host name.
82 | */
83 | readonly host: string;
84 |
85 | /**
86 | * The domain.
87 | */
88 | readonly domain: string;
89 |
90 | /**
91 | * The network interface index
92 | */
93 | readonly iface: number;
94 |
95 | /**
96 | * The IP protocol version.
97 | */
98 | readonly ipv: 'IPv4' | 'IPv6';
99 |
100 | /**
101 | * The IP address.
102 | */
103 | readonly address: string;
104 |
105 | /**
106 | * This IP port.
107 | */
108 | readonly port: number;
109 |
110 | /**
111 | * The txt records as key/value pairs.
112 | */
113 | readonly txt: TxtRecords;
114 | }
115 |
116 | /**
117 | * Gets in instance of the Bonjour interface.
118 | *
119 | * It will try to use a platform-specific implementation. Or if one is not
120 | * present, it falls back to a pure js implementation.
121 | */
122 | export async function getInstance(): Promise {
123 | try {
124 | return await avahi.getInstance();
125 | }
126 | catch (err) {
127 | try {
128 | return dnssd.getInstance();
129 | }
130 | catch (err) {
131 | // fall back to pure-javascript implementation
132 | return bonjour.getInstance();
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from 'vscode';
2 | import * as temp from 'temp';
3 | import * as fs from 'fs';
4 | import * as os from 'os';
5 |
6 | const toastDuration = 5000;
7 |
8 | export function sanitizedDateString(date?: Date): string {
9 | const d = date || new Date();
10 | const pad = (num: number) => ("00" + num).slice(-2);
11 |
12 | // Months are zero-indexed
13 | return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
14 | }
15 |
16 | const tempDirs: { [sharedKey: string]: string } = {};
17 | export function getSharedTempDir(sharedKey: string): Promise {
18 | if (tempDirs[sharedKey]) {
19 | return Promise.resolve(tempDirs[sharedKey]);
20 | }
21 |
22 | return new Promise((resolve, reject) => {
23 | temp.track();
24 | temp.mkdir(sharedKey, (err, dirPath) => {
25 | if (err) {
26 | reject(err);
27 | }
28 | else {
29 | tempDirs[sharedKey] = dirPath;
30 | resolve(dirPath);
31 | }
32 | });
33 | });
34 | }
35 |
36 | /**
37 | * Checks a file for Windows line endings. If found, modifies the file to remove
38 | * the Windows line endings.
39 | * @param path The path to the file.
40 | * @returns true if the file was modified, otherwise false
41 | */
42 | export function normalizeLineEndings(path: string): Promise {
43 | return new Promise((resolve, reject) => {
44 | fs.readFile(path, { encoding: "utf8" }, (err, data) => {
45 | if (err) {
46 | reject(err);
47 | return;
48 | }
49 |
50 | const replace = data.replace("\r\n", "\n");
51 |
52 | if (replace === data) {
53 | // not changed
54 | resolve(false);
55 | return;
56 | }
57 |
58 | fs.writeFile(path, replace, (err) => {
59 | if (err) {
60 | reject(err);
61 | return;
62 | }
63 |
64 | resolve(true);
65 | });
66 | });
67 | });
68 | }
69 |
70 | export function openAndRead(path: string, offset: number, length: number, position: number): Promise {
71 | return new Promise((resolve, reject) => {
72 | fs.open(path, 'r', (err, fd) => {
73 | if (err) {
74 | reject(err);
75 | return;
76 | }
77 |
78 | const buffer = Buffer.alloc(length);
79 | fs.read(fd, buffer, offset, length, position, (err, bytesRead, buffer) => {
80 | fs.close(fd, err => console.log(err));
81 | if (err) {
82 | reject(err);
83 | return;
84 | }
85 | resolve(buffer);
86 | });
87 | });
88 | });
89 | }
90 |
91 | export async function verifyFileHeader(filePath: string, expectedHeader: Buffer | number[], offset: number = 0): Promise {
92 | const bufferExpectedHeader = Array.isArray(expectedHeader) ? Buffer.from(expectedHeader) : expectedHeader;
93 | const header = await openAndRead(filePath, 0, bufferExpectedHeader.length, offset);
94 | return header.compare(bufferExpectedHeader) === 0;
95 | }
96 |
97 | export function toastStatusBarMessage(message: string): void {
98 | vscode.window.setStatusBarMessage(message, toastDuration);
99 | }
100 |
101 | /**
102 | * Sets a context that can be use for when clauses in package.json
103 | *
104 | * This may become official vscode API some day.
105 | * https://github.com/Microsoft/vscode/issues/10471
106 | * @param context The context name
107 | */
108 | export function setContext(context: string, state: boolean): void {
109 | vscode.commands.executeCommand('setContext', context, state);
110 | }
111 |
112 | /**
113 | * Gets the runtime platform suitable for use in settings lookup.
114 | */
115 | export function getPlatform(): 'windows' | 'osx' | 'linux' | undefined {
116 | let platform: 'windows' | 'osx' | 'linux' | undefined;
117 | switch (os.platform()) {
118 | case 'win32':
119 | platform = 'windows';
120 | break;
121 | case 'darwin':
122 | platform = 'osx';
123 | break;
124 | case 'linux':
125 | platform = 'linux';
126 | break;
127 | }
128 | return platform;
129 | }
130 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ev3dev browser for Visual Studio Code
2 |
3 | This extension allows you to browse ev3dev devices from Visual Studio Code, send
4 | files to these devices and remotely run programs.
5 |
6 | Learn more about ev3dev at .
7 |
8 |
9 | ## Requirements
10 |
11 | This extension is only compatible with devices running **ev3dev-stretch**.
12 | It will not work with earlier versions of ev3dev.
13 |
14 | Additional information can be found on the [wiki].
15 |
16 | [wiki]: https://github.com/ev3dev/vscode-ev3dev-browser/wiki
17 |
18 |
19 | ## Features
20 |
21 | * **Discover devices**: Any connected ev3dev device should be automatically discovered.
22 | No configuration necessary.
23 |
24 | 
25 |
26 | 
27 |
28 | * **Remotely browse files**: Files for each device are listed just as they are in
29 | Brickman.
30 |
31 | 
32 |
33 | * **Download files to the device**: The current VS Code project can be sent to an
34 | ev3dev device with a single click.
35 |
36 | 
37 |
38 | * **Remotely run programs**: Click any executable file to run it.
39 |
40 | 
41 |
42 | Right-clicking works too.
43 |
44 | 
45 |
46 | Error messages will be displayed in the output pane.
47 |
48 | 
49 |
50 | * **Build, Download and Run with a single click (or F5)**: Create
51 | a `launch.json` file with an `"ev3devBrowser"` type to use this feature.
52 |
53 | ```json
54 | {
55 | "version": "0.2.0",
56 | "configurations": [
57 | {
58 | "name": "Download and Run",
59 | "type": "ev3devBrowser",
60 | "request": "launch",
61 | "program": "/home/robot/${workspaceRootFolderName}/hello",
62 | "preLaunchTask": "build"
63 | }
64 | ]
65 | }
66 | ```
67 |
68 |
69 | * **Start a remote SSH session**: You can start an SSH session in the terminal pane
70 | by right-clicking on a device.
71 |
72 | 
73 |
74 | * **Take a screenshot**: You can easily take screenshot by right-clicking
75 | a device.
76 |
77 | 
78 |
79 | 
80 |
81 |
82 | ## Extension Settings
83 |
84 | This extension contributes the following settings:
85 |
86 | * `ev3devBrowser.password`: If you changed the password on your ev3dev device,
87 | you will need to set the password here. If you want to manually enter the
88 | password when you connect or use public key authentication, set this to
89 | `null`.
90 | * `ev3devBrowser.env`: If you need to set environment variables for running
91 | remote programs, you can set them here. Each variable is defined as a
92 | key/value pair.
93 | * `ev3devBrowser.interactiveTerminal.env`: This is similar to `ev3devBrowser.env`
94 | but the environment variables are only applied when running a program in
95 | the interactive terminal.
96 | * `ev3devBrowser.download.include`: Use this to specify which files to
97 | included when downloading files to the remote device. Can use glob patterns.
98 | * `ev3devBrowser.download.exclude`: Use this to specify which files to
99 | exclude when downloading files to the remote device. Can use glob patterns.
100 | * `ev3devBrowser.download.directory`: By default files are downloaded to
101 | a folder with the same name as the VS Code project. Use this setting to
102 | save the project files somewhere else. Paths are relative to the `/home/robot`
103 | directory.
104 | * `ev3devBrowser.additionalDevices`: A list of additional devices to show in
105 | the list when connecting to a device. This should only be needed in cases
106 | where there are network problems interfering with device discover.
107 | * `ev3devBrowser.confirmDelete`: Setting to `false` will suppress the
108 | confirmation message when deleting a remote file or directory.
109 | * `ev3devBrowser.connectTimeout`: The connection timeout when connecting to a
110 | device. Longer times may fix "Timeout while waiting for handshake".
111 |
112 | More details and examples on the [wiki](https://github.com/ev3dev/vscode-ev3dev-browser/wiki/Settings).
113 |
--------------------------------------------------------------------------------
/src/brickd.ts:
--------------------------------------------------------------------------------
1 | import compareVersions = require('compare-versions');
2 | import * as events from "events";
3 | import * as readline from 'readline';
4 | import * as ssh2 from 'ssh2';
5 | import Observable from 'zen-observable';
6 |
7 | const minBrickdVersion = '1.1.0';
8 | const maxBrickdVersion = '2.0.0';
9 |
10 | enum BrickdConnectionState {
11 | start,
12 | handshake,
13 | watchPower,
14 | getBatteryVoltage,
15 | getSerialNum,
16 | ok,
17 | bad
18 | }
19 |
20 | /**
21 | * Connection to a brickd server.
22 | */
23 | export class Brickd extends events.EventEmitter {
24 | private _serialNumber = '';
25 |
26 | /**
27 | * Gets the serial number of the main board.
28 | */
29 | public get serialNumber(): string {
30 | return this._serialNumber;
31 | }
32 |
33 | public constructor(readonly channel: ssh2.ClientChannel) {
34 | super();
35 | const reader = readline.createInterface(channel);
36 | const observable = new Observable(observer => {
37 | reader.on('line', line => {
38 | observer.next(line);
39 | }).on('close', () => {
40 | observer.complete();
41 | });
42 | });
43 |
44 | let state = BrickdConnectionState.start;
45 | observable.forEach(line => {
46 | const [m1, ...m2] = line.split(' ');
47 |
48 | // emit messages
49 | if (m1 === "MSG") {
50 | this.emit('message', m2.join(' '));
51 | return;
52 | }
53 |
54 | // everything else is handled from state machine
55 | switch (state) {
56 | case BrickdConnectionState.start:
57 | if (m1 === "BRICKD") {
58 | const version = m2[1];
59 | if (compareVersions(version, minBrickdVersion) < 0) {
60 | state = BrickdConnectionState.bad;
61 | this.emit('error', new Error(`Brickd is too old. Please upgrade to version >= ${minBrickdVersion}`));
62 | break;
63 | }
64 | if (compareVersions(version, maxBrickdVersion) >= 0) {
65 | state = BrickdConnectionState.bad;
66 | this.emit('error', new Error('Brickd version is too new.'));
67 | break;
68 | }
69 | state = BrickdConnectionState.handshake;
70 | channel.write('YOU ARE A ROBOT\n');
71 | }
72 | else {
73 | state = BrickdConnectionState.bad;
74 | this.emit('error', new Error('Brickd server did not send expected welcome message.'));
75 | }
76 | break;
77 | case BrickdConnectionState.handshake:
78 | if (m1 === "OK") {
79 | state = BrickdConnectionState.watchPower;
80 | channel.write("WATCH POWER\n");
81 | }
82 | else if (m1 === "BAD") {
83 | state = BrickdConnectionState.bad;
84 | this.emit('error', new Error("Brickd handshake failed."));
85 | }
86 | break;
87 | case BrickdConnectionState.watchPower:
88 | if (m1 === "OK") {
89 | state = BrickdConnectionState.getBatteryVoltage;
90 | channel.write("GET system.battery.voltage\n");
91 | }
92 | else {
93 | state = BrickdConnectionState.bad;
94 | this.emit('error', new Error("Brickd failed to register for power events."));
95 | }
96 | break;
97 | case BrickdConnectionState.getBatteryVoltage:
98 | if (m1 === "OK") {
99 | this.emit('message', `PROPERTY system.battery.voltage ${m2.join(' ')}`);
100 | state = BrickdConnectionState.getSerialNum;
101 | channel.write("GET system.info.serial\n");
102 | }
103 | else {
104 | state = BrickdConnectionState.bad;
105 | this.emit('error', new Error("Brickd failed to get battery voltage"));
106 | }
107 | break;
108 | case BrickdConnectionState.getSerialNum:
109 | if (m1 === "OK") {
110 | this._serialNumber = m2.join(' ');
111 | state = BrickdConnectionState.ok;
112 | this.emit('ready');
113 | }
114 | else {
115 | state = BrickdConnectionState.bad;
116 | this.emit('error', new Error("Brickd failed to get serial number"));
117 | }
118 | break;
119 | }
120 | });
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 | All notable changes to the "ev3dev-browser" extension will be documented in this file.
3 |
4 |
5 |
6 | ## v1.2.1 - 2023-03-29
7 | ### Fixed
8 | - Fixed Windows path separator in "program" in `.vscode/launch.json` not converted to UNIX path.
9 | - Fixed running files with `#!` and Windows line endings (CRLF vs. LF).
10 | - Fixed no way to specify port for user-specified IP address.
11 |
12 | ## v1.2.0 - 2020-07-20
13 | ### Changed
14 | - Initial debug configuration has new example to run current file
15 | ### Fixed
16 | - Stop button does not kill all child processes
17 | - Activate extension on command palette command
18 | - Fix multiple network interfaces not updated on Windows when scanning for devices
19 | - Fix race condition when browsing for connected devices
20 | ### Added
21 | - ev3dev remote debugger is now a default debugger for Python files
22 |
23 | ## 1.1.0 - 2020-03-07
24 | ### Added
25 | - New "pause" button on debugger that sends SIGINT to remote process
26 | - New "interactiveTerminal" debugger option to run remote programs in
27 | interactive terminal instead of output pane
28 | - New setting for device connection timeout
29 | ### Fixed
30 | - Fix debugger restart button not working
31 | - Fix numbers not allowed in `ev3devBrowser.env` variable names
32 | ### Changed
33 | - SSH shell no longer requires native executable on Windows
34 | - Device connection timeout increased to 30 seconds
35 |
36 | ## 1.0.4 - 2019-04-26
37 | ### Fixed
38 | - Fix "Timed out while waiting for handshake" error
39 | - Fix not working on Linux without Avahi installed
40 |
41 | ## 1.0.3 - 2019-03-25
42 | ### Changed
43 | - `ev3devBrowser` debugger type no longer uses native executable.
44 | - SSH shell no longer uses native executable on Linux and Mac.
45 | ### Fixed
46 | - Fix debugger hanging when ev3dev Device Browser view is collapsed
47 |
48 | ## 1.0.2 - 2019-03-11
49 | ### Fixed
50 | - Files are not downloaded when using global launch configuration
51 | - No indication when zero files are downloaded
52 |
53 | ## 1.0.1 - 2019-02-02
54 | ### Fixed
55 | - Duplicate listed devices in quick-pick on Windows
56 | - SSH terminal not working
57 |
58 | ## 1.0.0 - 2019-01-31
59 | ### Fixed
60 | - When using "Download and run", only current project is downloaded instead of
61 | entire workspace
62 | ### Changed
63 | - Download progress is shown in notification instead of status bar
64 | - Minimum VS Code version updated to 1.30
65 | - Publisher changed to "ev3dev"
66 |
67 | ## 0.8.1 - 2018-07-14
68 | ### Fixed
69 | - Error when trying to use file paths containing spaces (@WasabiFan)
70 |
71 | ## 0.8.0 - 2017-11-09
72 | ### Fixed
73 | - Current working directory is not the same as when running programs with Brickman
74 | - Context menu shown on root folder in remote file browser
75 | ### Changed
76 | - Upload command remembers selected directory for each workspace
77 |
78 | ## 0.7.0 - 2017-10-24
79 | ### Added
80 | - Multi-root workspace support
81 | - Upload command
82 | ### Fixed
83 | - Backslashes in directory names when downloading (Windows only)
84 | - Cannot run remote files (Windows only)
85 |
86 | ## 0.6.0 - 2017-10-18
87 | ### Added
88 | - Context menu item to connect to a different device
89 | - Context menu item to show file info
90 | ### Changed
91 | - Remote directories can be deleted
92 | - Downloads can be canceled
93 | ### Fixed
94 | - Connection timeout issues with Bluetooth and Wi-Fi
95 |
96 | ## 0.5.0 - 2017-09-14
97 | ### Added
98 | - Battery voltage monitoring
99 | - Refresh command/button
100 | ### Removed
101 | - ev3devBrowser.visible configuration setting (@WasabiFan)
102 | ## Changed
103 | - DNS-SD device discovery uses IPv6 instead of IPv4
104 |
105 | ## 0.4.0 - 2017-09-04
106 | ### Added
107 | - Command to get system info from remote device (@WasabiFan)
108 | - Configuration option and UI for adding devices that are not automatically
109 | discovered
110 | ### Fixed
111 | - Incorrect date stamp in screenshots (@WasabiFan)
112 | - Device still shows connected when the device is unplugged or the network is
113 | disconnected
114 | - Tree view commands listed in command palette
115 |
116 | ## 0.3.1 - 2017-08-26
117 | ### Fixed
118 | - Extra development files published with extension, resulting in large download
119 |
120 | ## 0.3.0 - 2017-08-26
121 | ### Added
122 | - Debugger contribution point to allow download and run by pressing F5
123 | - Device (re)connect/disconnect commands
124 | - Command to capture a screenshot from the remote device (@WasabiFan)
125 | ### Changed
126 | - Connect button is now an item in the tree view
127 | ### Fixed
128 | - Download button shown when no device is connected
129 | - Extra commands listed in command palette
130 | - Device context menu shown when device not connected
131 | - Fix downloading projects with subdirectories
132 |
133 | ## 0.2.0 - 2017-08-15
134 | ### Added
135 | - Optional interactive password prompt
136 | - Delete context menu item to delete remote files
137 | - Connect button to initiate connection to a device
138 | ### Changed
139 | - SSH sessions use internal shared connection instead of depending on
140 | external `ssh` and `plink.exe` programs
141 | - File names are now sorted
142 | - Device discovery improvements
143 | - Improved handling of device disconnection
144 | - Only connect to one device at a time
145 | - Device browser can now be hidden via settings
146 |
147 | ## 0.1.0 - 2017-07-26
148 | - Initial release
149 |
--------------------------------------------------------------------------------
/src/dnssd/dnssd.ts:
--------------------------------------------------------------------------------
1 | // This implements the interface from the 'bonjour' npm package using the
2 | // dns-sd command. Not all features are implemented.
3 |
4 | import * as events from 'events';
5 |
6 | import * as dns from './dnssd-client';
7 | import * as dnssd from '../dnssd';
8 |
9 | export function getInstance(): dnssd.Client {
10 | if (!dns.checkDaemonRunning()) {
11 | throw new Error('Could not find mDNSResponder');
12 | }
13 | return new DnssdClient();
14 | }
15 |
16 | class DnssdClient implements dnssd.Client {
17 | private destroyOps = new Array<() => void>();
18 |
19 | // interface method implementation
20 | public createBrowser(options: dnssd.BrowseOptions): Promise {
21 | const browser = new DnssdBrowser(this, options);
22 | return Promise.resolve(browser);
23 | }
24 |
25 | // interface method implementation
26 | public destroy(): void {
27 | this.destroyOps.forEach(op => op());
28 | this.destroyOps.length = 0;
29 | }
30 |
31 | /**
32 | * Adds an operation to be performed when destroy() is called.
33 | * @param op operation to add
34 | * @return the op argument
35 | */
36 | pushDestroyOp(op: () => void): () => void {
37 | this.destroyOps.push(op);
38 | return op;
39 | }
40 |
41 | /**
42 | * Removes an operation that was added with pushDestroyOp()
43 | * @param op the operation to remove
44 | */
45 | popDestroyOp(op: () => void): void {
46 | let i = this.destroyOps.findIndex(v => v === op);
47 | if (i >= 0) {
48 | this.destroyOps.splice(i, 1);
49 | }
50 | }
51 | }
52 |
53 | class DnssdBrowser extends events.EventEmitter implements dnssd.Browser {
54 | private service: dns.Service | undefined;
55 | private destroyOp: () => void;
56 | readonly services: DnssdService[] = new Array();
57 |
58 | constructor(private dnssd: DnssdClient, private options: dnssd.BrowseOptions) {
59 | super();
60 | this.destroyOp = this.dnssd.pushDestroyOp(() => this.destroy());
61 | }
62 |
63 | public async start(): Promise {
64 | const regType = `_${this.options.service}._${this.options.transport || 'tcp'}`;
65 | const domain = ''; // TODO: is this part of options?
66 |
67 | this.service = await dns.Service.browse(0, 0, regType, domain, async (s, f, i, e, n, t, d) => {
68 | if (e) {
69 | this.emit('error', new dns.ServiceError(e, 'Error while browsing.'));
70 | return;
71 | }
72 | if (f & dns.ServiceFlags.Add) {
73 | const resolveService = await s.resolve(0, i, n, t, d, async (s, f, i, e, fn, h, p, txt) => {
74 | if (e) {
75 | this.emit('error', new dns.ServiceError(e, 'Resolving service failed.'));
76 | return;
77 | }
78 | const addrService = await s.getAddrInfo(0, i, dns.ServiceProtocol.IPv6, h,
79 | (s, f, i, e, h, a, ttl) => {
80 | if (e) {
81 | this.emit('error', new dns.ServiceError(e, 'Querying service failed.'));
82 | return;
83 | }
84 | if (this.services.findIndex(v => v.iface === i && v.name === n && v.type === t && v.domain === d.replace(/\.$/, '')) !== -1) {
85 | // ignore duplicates
86 | return;
87 | }
88 | const service = new DnssdService(i, n, t, d, h, a, p, txt);
89 | this.services.push(service);
90 | this.emit('added', service);
91 | });
92 | await addrService.processResult();
93 | addrService.destroy();
94 | });
95 | await resolveService.processResult();
96 | resolveService.destroy();
97 | }
98 | else {
99 | const index = this.services.findIndex(s => s.match(i, n, t, d));
100 | if (index >= 0) {
101 | const [service] = this.services.splice(index, 1);
102 | this.emit('removed', service);
103 | }
104 | }
105 | });
106 |
107 | // process received results in the background
108 | (async () => {
109 | while (this.service) {
110 | try {
111 | await this.service.processResult();
112 | } catch (err) {
113 | this.emit('error', err);
114 | }
115 | }
116 | })();
117 | }
118 |
119 | public async stop(): Promise {
120 | this.service?.destroy();
121 | this.service = undefined;
122 | }
123 |
124 | destroy(): void {
125 | this.removeAllListeners();
126 | this.stop();
127 | this.dnssd.popDestroyOp(this.destroyOp);
128 | }
129 | }
130 |
131 | class DnssdService extends events.EventEmitter implements dnssd.Service {
132 | public readonly service: string;
133 | public readonly transport: 'tcp' | 'udp';
134 | public readonly host: string;
135 | public readonly domain: string;
136 | public readonly ipv: 'IPv4' | 'IPv6';
137 | public readonly txt: dnssd.TxtRecords;
138 |
139 | constructor(
140 | public readonly iface: number,
141 | public readonly name: string,
142 | readonly type: string,
143 | domain: string,
144 | host: string,
145 | public readonly address: string,
146 | public readonly port: number,
147 | txt: string[]) {
148 | super();
149 | const [service, transport] = type.split('.');
150 | // remove leading '_'
151 | this.service = service.slice(1);
152 | this.transport = <'tcp' | 'udp'>transport.slice(1);
153 | // strip trailing '.'
154 | this.host = host.replace(/\.$/, '');
155 | this.domain = domain.replace(/\.$/, '');
156 | this.ipv = 'IPv6';
157 | this.txt = DnssdService.parseText(txt);
158 | }
159 |
160 | match(iface: number, name: string, type: string, domain: string): boolean {
161 | return this.iface === iface && this.name === name && this.type === type && this.domain === domain;
162 | }
163 |
164 | private static parseText(txt: string[]): dnssd.TxtRecords {
165 | const result = new Object();
166 | if (!txt) {
167 | return result;
168 | }
169 |
170 | txt.forEach(v => {
171 | const [key, value] = v.split(/=/);
172 | result[key] = value;
173 | });
174 |
175 | return result;
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/src/dnssd/bonjour.ts:
--------------------------------------------------------------------------------
1 |
2 | import bonjour from 'bonjour';
3 | import * as events from 'events';
4 | import * as os from 'os';
5 |
6 | import * as dnssd from '../dnssd';
7 |
8 | export function getInstance(): dnssd.Client {
9 | return new BonjourClient();
10 | }
11 |
12 | class BonjourClient extends events.EventEmitter implements dnssd.Client {
13 | private readonly bClients: { [ifaceAddress: string]: bonjour.Bonjour } = {};
14 | private readonly ifaceAddresses = new Array();
15 | private readonly ifaceTimer = setInterval(() => this.updateInterfaces(), 500);
16 |
17 | forEachClient(func: (bClient: bonjour.Bonjour) => void) {
18 | for (const a in this.bClients) {
19 | func(this.bClients[a]);
20 | }
21 | }
22 |
23 | public createBrowser(opts: dnssd.BrowseOptions): Promise {
24 | const browser = new BonjourBrowser(this, opts);
25 | return Promise.resolve(browser);
26 | }
27 |
28 | public destroy(): void {
29 | clearInterval(this.ifaceTimer);
30 | for (const a in this.bClients) {
31 | this.destroyClient(a);
32 | }
33 | this.removeAllListeners();
34 | }
35 |
36 | // The bonjour package doesn't seem to be able to handle broadcasting and
37 | // receiving on all interfaces. So, we are monitoring network interfaces
38 | // ourselves and creating a bonjour.Bonjour instance for each network
39 | // interface (actually, each address of each interface, which could be
40 | // more than one).
41 | private updateInterfaces() {
42 | type Address = { iface: number, address: string };
43 | const newAddresses = new Array();
44 | const ifaces = os.networkInterfaces();
45 | for (let i in ifaces) {
46 | // on Windows, only the local link address has a scopeid that matches
47 | // the index of the network interface.
48 | const localLinkAddr = ifaces[i].find(v => v.address.startsWith('fe80:'));
49 | if (!localLinkAddr) {
50 | continue;
51 | }
52 | const ifaceIndex = (localLinkAddr).scopeid;
53 |
54 | // only supporting IPv6 for now
55 | const addresses = ifaces[i].filter(v => v.internal === false && v.family === 'IPv6').map(v =>
56 | `${v.address}%${process.platform === 'win32' ? (v).scopeid : i}`);
57 | newAddresses.push(...addresses.map(v => { iface: ifaceIndex, address: v }));
58 | }
59 | const added = newAddresses.filter(a => this.ifaceAddresses.indexOf(a.address) === -1);
60 | const removed = this.ifaceAddresses.filter(a => newAddresses.findIndex(v => v.address === a) === -1);
61 | if (added.length) {
62 | for (const a of added) {
63 | this.ifaceAddresses.push(a.address);
64 | this.createClient(a.iface, a.address);
65 | }
66 | }
67 | if (removed.length) {
68 | const indexes = removed.map(a => this.ifaceAddresses.indexOf(a));
69 | indexes.forEach(i => {
70 | const [a] = this.ifaceAddresses.splice(i, 1);
71 | this.destroyClient(a);
72 | }, this);
73 | }
74 | }
75 |
76 | /**
77 | * Asynchronously create an new bonjour.Bonjour client object
78 | * @param ifaceIndex the index of the network interface
79 | * @param ifaceAddress the IP address
80 | */
81 | private createClient(ifaceIndex: number, ifaceAddress: string): void {
82 | // On Windows, we need the full IP address as part of the multicast socket
83 | // interface or things don't work right. On Linux, we have to strip the
84 | // IP address or things don't work right.
85 | const iface = (os.platform() === 'win32') ? ifaceAddress : ifaceAddress.replace(/.*%/, '::%');
86 |
87 | // work around bonjour issue where error is not handled
88 | new Promise((resolve, reject) => {
89 | const bClient = bonjour({
90 | type: 'udp6',
91 | ip: 'ff02::fb',
92 | interface: iface,
93 | });
94 | (bClient)['iface'] = ifaceIndex;
95 | (bClient)._server.mdns.on('ready', () => resolve(bClient));
96 | (bClient)._server.mdns.on('error', (err: any) => reject(err));
97 | }).then(bClient => {
98 | if (this.ifaceAddresses.indexOf(ifaceAddress) < 0) {
99 | // iface was removed while we were waiting for promise
100 | bClient.destroy();
101 | return;
102 | }
103 | this.bClients[ifaceAddress] = bClient;
104 | this.emit('clientAdded', bClient);
105 | }).catch(err => {
106 | if (err.code === 'EADDRNOTAVAIL') {
107 | // when a new network interface first comes up, we can get this
108 | // error when we try to bind to the socket, so keep trying until
109 | // we are bound or the interface goes away.
110 | setTimeout(() => {
111 | if (this.ifaceAddresses.indexOf(ifaceAddress) >= 0) {
112 | this.createClient(ifaceIndex, ifaceAddress);
113 | }
114 | }, 500);
115 | }
116 | // FIXME: other errors are currently ignored
117 | });
118 | }
119 |
120 | /**
121 | * Destroys the bonjour.Bonjour client associated with ifaceAddress
122 | * @param ifaceAddress the IP address
123 | */
124 | private destroyClient(ifaceAddress: string): void {
125 | const bClient = this.bClients[ifaceAddress];
126 | delete this.bClients[ifaceAddress];
127 | this.emit('clientRemoved', bClient);
128 | bClient.destroy();
129 | }
130 | }
131 |
132 | /** Per-client browser object. */
133 | type ClientBrowser = {
134 | /** Bonjour client associated with specific network interface and address. */
135 | bClient: bonjour.Bonjour,
136 | /** Bonjour browser for the Bonjour client. */
137 | browser: bonjour.Browser,
138 | /** Services discovered by the browser. */
139 | services: BonjourService[],
140 | /** Update timer - undefined if not started. */
141 | updateInterval?: NodeJS.Timer,
142 | };
143 |
144 | class BonjourBrowser extends events.EventEmitter implements dnssd.Browser {
145 | private started = false;
146 | private readonly browsers = new Array();
147 |
148 | constructor(private readonly client: BonjourClient, private readonly opts: dnssd.BrowseOptions) {
149 | super();
150 | this.addBrowser = this.addBrowser.bind(this);
151 | this.removeBrowser = this.removeBrowser.bind(this);
152 | client.on('clientAdded', this.addBrowser);
153 | client.on('clientRemoved', this.removeBrowser);
154 | client.forEachClient(c => this.addBrowser(c));
155 | }
156 |
157 | public async start(): Promise {
158 | for (const b of this.browsers) {
159 | this.startClientBrowser(b);
160 | }
161 | this.started = true;
162 | }
163 |
164 | public async stop(): Promise {
165 | for (const b of this.browsers) {
166 | this.stopClientBrowser(b);
167 | }
168 | this.started = false;
169 | }
170 |
171 | public destroy(): void {
172 | this.removeAllListeners();
173 | this.client.off('clientAdded', this.addBrowser);
174 | this.client.off('clientRemoved', this.removeBrowser);
175 | this.stop();
176 | }
177 |
178 | private addBrowser(bClient: bonjour.Bonjour) {
179 | const browser = bClient.find({
180 | type: this.opts.service,
181 | protocol: this.opts.transport,
182 | });
183 | const services = new Array();
184 | browser.on('up', s => {
185 | (s)['iface'] = (bClient)['iface'];
186 | for (const b of this.browsers) {
187 | for (const bs of b.services) {
188 | const bss = bs.bService;
189 | if ((s)['iface'] === (bss)['iface'] && s.name === bs.name && s.type === bss.type && s.fqdn === bss.fqdn.replace(/\.$/, '')) {
190 | // ignore duplicates
191 | return;
192 | }
193 | }
194 | }
195 | const service = new BonjourService(s);
196 | services.push(service);
197 | this.emit('added', service, false);
198 | });
199 | browser.on('down', s => {
200 | const index = services.findIndex(v => v.bService === s);
201 | const [service] = services.splice(index, 1);
202 | this.emit('removed', service, false);
203 | });
204 | const clientBrowser = { bClient: bClient, browser: browser, services: services };
205 | this.browsers.push(clientBrowser);
206 |
207 | // If a new client is added after we have already started browsing, we need
208 | // to start that browser as well.
209 | if (this.started) {
210 | this.startClientBrowser(clientBrowser);
211 | }
212 | }
213 |
214 | private removeBrowser(bClient: bonjour.Bonjour): void {
215 | const i = this.browsers.findIndex(v => v.bClient === bClient);
216 | const [removed] = this.browsers.splice(i, 1);
217 | this.stopClientBrowser(removed);
218 | for (const s of removed.services) {
219 | this.emit('removed', s);
220 | }
221 | }
222 |
223 | private startClientBrowser(clientBrowser: ClientBrowser): void {
224 | clientBrowser.browser.start();
225 | clientBrowser.updateInterval = setInterval(() => {
226 | // poll again every 1 second
227 | clientBrowser.browser.update();
228 | }, 1000);
229 | }
230 |
231 | private stopClientBrowser(clientBrowser: ClientBrowser): void {
232 | if (clientBrowser.updateInterval) {
233 | clearInterval(clientBrowser.updateInterval);
234 | clientBrowser.browser.stop();
235 | }
236 | }
237 | }
238 |
239 | class BonjourService implements dnssd.Service {
240 | public readonly name: string;
241 | public readonly service: string;
242 | public readonly transport: 'tcp' | 'udp';
243 | public readonly iface: number;
244 | public readonly host: string;
245 | public readonly domain: string;
246 | public readonly ipv: 'IPv4' | 'IPv6';
247 | public readonly address: string;
248 | public readonly port: number;
249 | public readonly txt: dnssd.TxtRecords;
250 |
251 | constructor(public readonly bService: bonjour.Service) {
252 | this.name = bService.name;
253 | this.service = bService.type;
254 | this.transport = <'tcp' | 'udp'>bService.protocol;
255 | this.iface = (bService)['iface'];
256 | this.host = bService.host;
257 | this.domain = (bService).domain;
258 | this.ipv = 'IPv6';
259 | this.address = (bService).addresses[0]; // FIXME
260 | this.port = bService.port;
261 | this.txt = bService.txt;
262 | }
263 | }
264 |
--------------------------------------------------------------------------------
/src/dnssd/avahi.ts:
--------------------------------------------------------------------------------
1 | // This implements the interface from the 'bonjour' npm package using the
2 | // avahi-browse command. Not all features are implemented.
3 |
4 | import * as dbus from 'dbus-next';
5 | import * as events from 'events';
6 |
7 | import * as dnssd from '../dnssd';
8 |
9 | const PROTO_INET = 0;
10 | const PROTO_INET6 = 1;
11 | const IF_UNSPEC = -1;
12 |
13 | interface Server extends dbus.ClientInterface {
14 | GetVersionString(): Promise;
15 | GetAPIVersion(): Promise;
16 | GetHostName(): Promise;
17 | SetHostName(name: string): Promise;
18 | GetHostNameFqdn(): Promise;
19 | GetDomainName(): Promise;
20 | IsNSSSupportAvailable(): Promise;
21 | GetState(): Promise;
22 | on(event: 'StateChanged', listener: (state: number, error: string) => void): this;
23 | GetLocalServiceCookie(): Promise;
24 | GetAlternativeHostName(name: string): Promise;
25 | GetAlternativeServiceName(name: string): Promise;
26 | GetNetworkInterfaceNameByIndex(index: number): Promise;
27 | GetNetworkInterfaceIndexByName(name: string): Promise;
28 | ResolveHostName(iface: number, protocol: number, name: string, aprotocol: number, flags: number): Promise void>>;
29 | ResolveAddress(iface: number, protocol: number, address: string, flags: number): Promise void>>;
30 | ResolveService(iface: number, protocol: number, name: string, type: string, domain: string, aprotocol: number, flags: number): Promise void>>;
31 | EntryGroupNew(): Promise;
32 | DomainBrowserNew(iface: number, protocol: number, domain: string, btype: number, flags: number): Promise;
33 | ServiceTypeBrowserNew(iface: number, protocol: number, domain: string, flags: number): Promise;
34 | ServiceBrowserNew(iface: number, protocol: number, type: string, domain: string, flags: number): Promise;
35 | ServiceResolverNew(iface: number, protocol: number, name: string, type: string, domain: string, aprotocol: number, flags: number): Promise;
36 | HostNameResolverNew(iface: number, protocol: number, name: string, aprotocol: number, flags: number): Promise;
37 | AddressResolverNew(iface: number, protocol: number, address: string, flags: number): Promise;
38 | RecordBrowserNew(iface: number, protocol: number, name: string, clazz: number, type: number, flags: number): Promise;
39 | }
40 |
41 | interface ServiceBrowser extends dbus.ClientInterface {
42 | Free(): Promise;
43 | // Can't use signal handlers on proxy due to race condition: https://github.com/lathiat/avahi/issues/9
44 | // on(event: 'ItemNew', listener: (iface: number, protocol: number, name: string, type: string, domain: string, flags: number) => void): this;
45 | // on(event: 'ItemRemove', listener: (iface: number, protocol: number, name: string, type: string, domain: string, flags: number) => void): this;
46 | // on(event: 'Failure', listener: (error: string) => void): this;
47 | // on(event: 'AllForNow', listener: () => void): this;
48 | // on(event: 'CacheExhausted', listener: () => void): this;
49 | }
50 |
51 | type ServerObject = {
52 | proxy: dbus.ProxyObject;
53 | iface: Server;
54 | };
55 |
56 | let cachedServer: ServerObject | undefined;
57 |
58 | async function getServer(): Promise {
59 | if (cachedServer === undefined) {
60 | const bus = dbus.systemBus();
61 | // dbus-next will queue messages and wait forever for a connection
62 | // so we have to hack in a timeout, otherwise we end up with a deadlock
63 | // on systems without D-Bus.
64 | await new Promise((resolve, reject) => {
65 | const timeout = setTimeout(() => {
66 | reject(Error("Timeout while connecting to D-Bus"));
67 | }, 100);
68 | (bus as any).on('connect', () => {
69 | clearTimeout(timeout);
70 | resolve();
71 | });
72 | });
73 | const proxy = await bus.getProxyObject('org.freedesktop.Avahi', '/');
74 | const iface = proxy.getInterface('org.freedesktop.Avahi.Server');
75 | const version = await iface.GetAPIVersion();
76 | cachedServer = { proxy, iface };
77 | }
78 |
79 | return cachedServer;
80 | }
81 |
82 | export async function getInstance(): Promise {
83 | const server = await getServer();
84 | return new AvahiClient(server);
85 | }
86 |
87 | class AvahiClient implements dnssd.Client {
88 | private destroyOps = new Array<() => void>();
89 |
90 | constructor(public readonly server: ServerObject) {
91 | }
92 |
93 | public createBrowser(options: dnssd.BrowseOptions): Promise {
94 | return new Promise((resolve, reject) => {
95 | const browser = new AvahiBrowser(this, options);
96 | browser.once('ready', () => {
97 | browser.removeAllListeners('error');
98 | resolve(browser);
99 | });
100 | browser.once('error', err => {
101 | browser.removeAllListeners('ready');
102 | reject(err);
103 | });
104 | });
105 | }
106 |
107 | // interface method implementation
108 | public destroy(): void {
109 | this.destroyOps.forEach(op => op());
110 | this.destroyOps.length = 0;
111 | }
112 |
113 | /**
114 | * Adds an operation to be performed when destroy() is called.
115 | * @param op operation to add
116 | * @return the op argument
117 | */
118 | pushDestroyOp(op: () => void): () => void {
119 | this.destroyOps.push(op);
120 | return op;
121 | }
122 |
123 | /**
124 | * Removes an operation that was added with pushDestroyOp()
125 | * @param op the operation to remove
126 | */
127 | popDestroyOp(op: () => void): void {
128 | let i = this.destroyOps.findIndex(v => v === op);
129 | if (i >= 0) {
130 | this.destroyOps.splice(i, 1);
131 | }
132 | }
133 | }
134 |
135 | class AvahiBrowser extends events.EventEmitter implements dnssd.Browser {
136 | private browser: ServiceBrowser | undefined;
137 | private readonly services: AvahiService[] = new Array();
138 | private readonly bus: dbus.MessageBus;
139 |
140 | constructor(private readonly client: AvahiClient, private options: dnssd.BrowseOptions) {
141 | super();
142 | // Due to race condition: https://github.com/lathiat/avahi/issues/9
143 | // we have to add signal listeners now before creating browser objects
144 | // otherwise we miss signals.
145 | this.bus = client.server.proxy.bus;
146 | (this.bus as any).on('message', (msg: dbus.Message) => {
147 | if (msg.type !== dbus.MessageType.SIGNAL) {
148 | return;
149 | }
150 | if (msg.interface !== 'org.freedesktop.Avahi.ServiceBrowser') {
151 | return;
152 | }
153 | // TODO: should also check msg.path, but we can receive messages
154 | // before ServiceBrowserNew() returns when we don't know the path
155 | // yet.
156 | switch (msg.member) {
157 | case 'ItemNew': {
158 | const [iface, protocol, name, type, domain, flags] = msg.body;
159 | client.server.iface.ResolveService(iface, protocol, name, type, domain, protocol, 0).then(
160 | ([iface, protocol, name, type, domain, host, aprotocol, addr, port, txt, flags]) => {
161 | const service = new AvahiService(iface, protocol, name, type, domain, host, aprotocol, addr, port, txt, flags);
162 | this.services.push(service);
163 | this.emit('added', service);
164 | });
165 | }
166 | break;
167 | case 'ItemRemove': {
168 | const [iface, protocol, name, type, domain, flags] = msg.body;
169 | const i = this.services.findIndex(s => s.match(iface, protocol, name, type, domain));
170 | if (i >= 0) {
171 | const [service] = this.services.splice(i, 1);
172 | this.emit('removed', service);
173 | }
174 | }
175 | break;
176 | case 'Failure': {
177 | const [error] = msg.body;
178 | this.emit('error', new Error(error));
179 | }
180 | break;
181 | }
182 | });
183 | const addMatchMessage = new dbus.Message({
184 | destination: 'org.freedesktop.DBus',
185 | path: '/org/freedesktop/DBus',
186 | interface: 'org.freedesktop.DBus',
187 | member: 'AddMatch',
188 | signature: 's',
189 | body: [`type='signal',sender='org.freedesktop.Avahi',interface='org.freedesktop.Avahi.ServiceBrowser'`]
190 | });
191 | this.bus.call(addMatchMessage).then(() => this.emit('ready')).catch((err) => this.emit('error', err));
192 | }
193 |
194 | public async start(): Promise {
195 | const proto = this.options.ipv === 'IPv6' ? PROTO_INET6 : PROTO_INET;
196 | const type = `_${this.options.service}._${this.options.transport || 'tcp'}`;
197 | const objPath = await this.client.server.iface.ServiceBrowserNew(IF_UNSPEC, proto, type, '', 0);
198 | const proxy = await this.bus.getProxyObject('org.freedesktop.Avahi', objPath);
199 | this.browser = proxy.getInterface('org.freedesktop.Avahi.ServiceBrowser');
200 | }
201 |
202 | public async stop(): Promise {
203 | await this.browser?.Free();
204 | this.browser = undefined;
205 | }
206 |
207 | destroy(): void {
208 | this.removeAllListeners();
209 | this.stop();
210 | }
211 |
212 | }
213 |
214 | class AvahiService implements dnssd.Service {
215 | public readonly service: string;
216 | public readonly transport: 'tcp' | 'udp';
217 | public readonly ipv: 'IPv4' | 'IPv6';
218 | public readonly txt: dnssd.TxtRecords;
219 |
220 | constructor(
221 | public readonly iface: number,
222 | private readonly protocol: number,
223 | public readonly name: string,
224 | private readonly type: string,
225 | public readonly domain: string,
226 | public readonly host: string,
227 | aprotocol: number,
228 | public readonly address: string,
229 | public readonly port: number,
230 | txt: Buffer[],
231 | flags: number) {
232 | const [service, transport] = type.split('.');
233 | // remove leading '_'
234 | this.service = service.slice(1);
235 | this.transport = <'tcp' | 'udp'>transport.slice(1);
236 | this.ipv = protocol === PROTO_INET6 ? 'IPv6' : 'IPv4';
237 | this.txt = AvahiService.parseText(txt);
238 | }
239 |
240 | match(iface: number, protocol: number, name: string, type: string, domain: string): boolean {
241 | return this.iface === iface && this.protocol === protocol &&
242 | this.name === name && this.type === type && this.domain === domain;
243 | }
244 |
245 | private static parseText(txt?: Buffer[]): dnssd.TxtRecords {
246 | const result = new Object();
247 | if (txt) {
248 | txt.forEach(v => {
249 | // dbus-next is supposed to treat array of bytes as buffer but
250 | // it currently treats it as a regular array of numbers.
251 | if (!(v instanceof Buffer)) {
252 | v = Buffer.from(v);
253 | }
254 | const [key, value] = v.toString().split(/=/);
255 | result[key] = value;
256 | });
257 | }
258 | return result;
259 | }
260 | }
261 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ev3dev-browser",
3 | "displayName": "ev3dev-browser",
4 | "description": "Browse for ev3dev devices",
5 | "icon": "resources/icons/ev3dev-logo.png",
6 | "version": "1.2.1",
7 | "publisher": "ev3dev",
8 | "license": "MIT",
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/ev3dev/vscode-ev3dev-browser.git"
12 | },
13 | "bugs": {
14 | "url": "https://github.com/ev3dev/vscode-ev3dev-browser/issues"
15 | },
16 | "engines": {
17 | "vscode": "^1.39.0"
18 | },
19 | "categories": [
20 | "Other"
21 | ],
22 | "activationEvents": [
23 | "onView:ev3devBrowser",
24 | "onDebugResolve:ev3devBrowser",
25 | "onCommand:ev3devBrowser.action.pickDevice"
26 | ],
27 | "main": "./out/main.js",
28 | "contributes": {
29 | "configuration": {
30 | "title": "ev3dev browser configuration",
31 | "properties": {
32 | "ev3devBrowser.password": {
33 | "scope": "window",
34 | "type": [
35 | "string",
36 | "null"
37 | ],
38 | "default": "maker",
39 | "description": "The password for the 'robot' user. Set to \"null\" to prompt for password (or use public key authentication)."
40 | },
41 | "ev3devBrowser.env": {
42 | "scope": "window",
43 | "type": "object",
44 | "patternProperties": {
45 | "[A-Za-z0-9_]{1,}": {
46 | "type": "string"
47 | }
48 | },
49 | "additionalProperties": false,
50 | "default": {
51 | "PYTHONUNBUFFERED": "TRUE"
52 | },
53 | "description": "Addition environment variables to use on remote devices.",
54 | "uniqueItems": true
55 | },
56 | "ev3devBrowser.interactiveTerminal.env": {
57 | "scope": "window",
58 | "type": "object",
59 | "patternProperties": {
60 | "[A-Za-z0-9_]{1,}": {
61 | "type": "string"
62 | }
63 | },
64 | "additionalProperties": false,
65 | "default": {
66 | "PYTHONINSPECT": "TRUE",
67 | "MICROPYINSPECT": "TRUE"
68 | },
69 | "description": "Addition environment variables to use on remote devices only when using the interactive terminal that is started by the debugger.",
70 | "uniqueItems": true
71 | },
72 | "ev3devBrowser.download.include": {
73 | "scope": "resource",
74 | "type": "string",
75 | "default": "**/*",
76 | "description": "Files to include when sending project to remote devices."
77 | },
78 | "ev3devBrowser.download.exclude": {
79 | "scope": "resource",
80 | "type": "string",
81 | "default": "**/.*",
82 | "description": "Files to exclude when sending project to remote devices."
83 | },
84 | "ev3devBrowser.download.directory": {
85 | "scope": "resource",
86 | "type": [
87 | "string",
88 | "null"
89 | ],
90 | "default": null,
91 | "description": "The directory on the remote device where the files will be saved. The default is to use the name of the vscode project directory."
92 | },
93 | "ev3devBrowser.confirmDelete": {
94 | "scope": "application",
95 | "type": "boolean",
96 | "default": true,
97 | "description": "Prompt for confirmation before deleting remote files."
98 | },
99 | "ev3devBrowser.additionalDevices": {
100 | "scope": "machine",
101 | "type": "array",
102 | "items": {
103 | "type": "object",
104 | "properties": {
105 | "name": {
106 | "type": "string",
107 | "pattern": "[a-zA-Z0-9_\\-]{1,}"
108 | },
109 | "ipAddress": {
110 | "type": "string",
111 | "pattern": "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?"
112 | },
113 | "username": {
114 | "type": "string",
115 | "pattern": "[a-zA-Z0-9_\\-]{1,}",
116 | "default": "robot"
117 | },
118 | "homeDirectory": {
119 | "type": "string",
120 | "default": "/home/robot"
121 | }
122 | },
123 | "required": [
124 | "name",
125 | "ipAddress"
126 | ]
127 | },
128 | "uniqueItems": true,
129 | "default": [],
130 | "description": "A list of devices to add to the pick list. This is intended to work around troublesome network connections, such as Bluetooth"
131 | },
132 | "ev3devBrowser.connectTimeout": {
133 | "scope": "application",
134 | "type": "integer",
135 | "default": 30,
136 | "description": "Device connection timeout in seconds."
137 | }
138 | }
139 | },
140 | "commands": [
141 | {
142 | "command": "ev3devBrowser.deviceTreeItem.openSshTerminal",
143 | "title": "Open SSH Terminal"
144 | },
145 | {
146 | "command": "ev3devBrowser.deviceTreeItem.captureScreenshot",
147 | "title": "Take Screenshot"
148 | },
149 | {
150 | "command": "ev3devBrowser.deviceTreeItem.showSysinfo",
151 | "title": "Get system info"
152 | },
153 | {
154 | "command": "ev3devBrowser.deviceTreeItem.reconnect",
155 | "title": "Reconnect"
156 | },
157 | {
158 | "command": "ev3devBrowser.deviceTreeItem.connectNew",
159 | "title": "Connect to a different device"
160 | },
161 | {
162 | "command": "ev3devBrowser.deviceTreeItem.disconnect",
163 | "title": "Disconnect"
164 | },
165 | {
166 | "command": "ev3devBrowser.fileTreeItem.run",
167 | "title": "Run"
168 | },
169 | {
170 | "command": "ev3devBrowser.fileTreeItem.runInTerminal",
171 | "title": "Run in interactive terminal"
172 | },
173 | {
174 | "command": "ev3devBrowser.fileTreeItem.delete",
175 | "title": "Delete"
176 | },
177 | {
178 | "command": "ev3devBrowser.fileTreeItem.showInfo",
179 | "title": "Show Info"
180 | },
181 | {
182 | "command": "ev3devBrowser.fileTreeItem.upload",
183 | "title": "Upload"
184 | },
185 | {
186 | "command": "ev3devBrowser.action.pickDevice",
187 | "title": "Connect to a device",
188 | "category": "ev3dev"
189 | },
190 | {
191 | "command": "ev3devBrowser.action.download",
192 | "title": "Send workspace to device",
193 | "icon": {
194 | "dark": "resources/icons/dark/download.svg",
195 | "light": "resources/icons/light/download.svg"
196 | },
197 | "category": "ev3dev"
198 | },
199 | {
200 | "command": "ev3devBrowser.action.refresh",
201 | "title": "Refresh",
202 | "icon": {
203 | "dark": "resources/icons/dark/refresh.svg",
204 | "light": "resources/icons/light/refresh.svg"
205 | },
206 | "category": "ev3dev"
207 | }
208 | ],
209 | "debuggers": [
210 | {
211 | "type": "ev3devBrowser",
212 | "label": "ev3dev",
213 | "program": "./out/debugServer.js",
214 | "runtime": "node",
215 | "languages": [
216 | "python"
217 | ],
218 | "configurationAttributes": {
219 | "launch": {
220 | "required": [
221 | "program"
222 | ],
223 | "properties": {
224 | "program": {
225 | "type": "string",
226 | "description": "Absolute path to an executable file on the remote device.",
227 | "default": "/home/robot/myproject/myprogram"
228 | },
229 | "interactiveTerminal": {
230 | "type": "boolean",
231 | "description": "When true, program will be run in a new interactive terminal, when false the output pane will be used instead.",
232 | "default": false
233 | }
234 | }
235 | }
236 | },
237 | "configurationSnippets": [
238 | {
239 | "label": "ev3dev: Download and Run",
240 | "description": "Configuration for downloading and running a program on an ev3dev device.",
241 | "body": {
242 | "name": "Download and Run",
243 | "type": "ev3devBrowser",
244 | "request": "launch",
245 | "program": "^\"/home/robot/\\${workspaceFolderBasename}/${1:myprogram}\"",
246 | "interactiveTerminal": false
247 | }
248 | }
249 | ],
250 | "initialConfigurations": [
251 | {
252 | "name": "Download and Run current file",
253 | "type": "ev3devBrowser",
254 | "request": "launch",
255 | "program": "/home/robot/${workspaceFolderBasename}/${relativeFile}",
256 | "interactiveTerminal": true
257 | },
258 | {
259 | "name": "Download and Run my-program",
260 | "type": "ev3devBrowser",
261 | "request": "launch",
262 | "program": "/home/robot/${workspaceFolderBasename}/my-program (replace 'my-program' with the actual path)",
263 | "interactiveTerminal": true
264 | }
265 | ]
266 | }
267 | ],
268 | "menus": {
269 | "commandPalette": [
270 | {
271 | "command": "ev3devBrowser.action.pickDevice"
272 | },
273 | {
274 | "command": "ev3devBrowser.action.download",
275 | "when": "ev3devBrowser.context.connected"
276 | },
277 | {
278 | "command": "ev3devBrowser.action.refresh",
279 | "when": "ev3devBrowser.context.connected"
280 | },
281 | {
282 | "command": "ev3devBrowser.deviceTreeItem.openSshTerminal",
283 | "when": "false"
284 | },
285 | {
286 | "command": "ev3devBrowser.deviceTreeItem.captureScreenshot",
287 | "when": "false"
288 | },
289 | {
290 | "command": "ev3devBrowser.deviceTreeItem.showSysinfo",
291 | "when": "false"
292 | },
293 | {
294 | "command": "ev3devBrowser.deviceTreeItem.reconnect",
295 | "when": "false"
296 | },
297 | {
298 | "command": "ev3devBrowser.deviceTreeItem.connectNew",
299 | "when": "false"
300 | },
301 | {
302 | "command": "ev3devBrowser.deviceTreeItem.disconnect",
303 | "when": "false"
304 | },
305 | {
306 | "command": "ev3devBrowser.fileTreeItem.run",
307 | "when": "false"
308 | },
309 | {
310 | "command": "ev3devBrowser.fileTreeItem.runInTerminal",
311 | "when": "false"
312 | },
313 | {
314 | "command": "ev3devBrowser.fileTreeItem.delete",
315 | "when": "false"
316 | },
317 | {
318 | "command": "ev3devBrowser.fileTreeItem.showInfo",
319 | "when": "false"
320 | },
321 | {
322 | "command": "ev3devBrowser.fileTreeItem.upload",
323 | "when": "false"
324 | }
325 | ],
326 | "view/title": [
327 | {
328 | "command": "ev3devBrowser.action.refresh",
329 | "group": "navigation",
330 | "when": "view == ev3devBrowser && ev3devBrowser.context.connected"
331 | },
332 | {
333 | "command": "ev3devBrowser.action.download",
334 | "group": "navigation",
335 | "when": "view == ev3devBrowser && ev3devBrowser.context.connected"
336 | }
337 | ],
338 | "view/item/context": [
339 | {
340 | "command": "ev3devBrowser.deviceTreeItem.reconnect",
341 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.disconnected",
342 | "group": "group@0"
343 | },
344 | {
345 | "command": "ev3devBrowser.deviceTreeItem.connectNew",
346 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.disconnected",
347 | "group": "group@1"
348 | },
349 | {
350 | "command": "ev3devBrowser.deviceTreeItem.disconnect",
351 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.connected",
352 | "group": "secondary@9"
353 | },
354 | {
355 | "command": "ev3devBrowser.deviceTreeItem.openSshTerminal",
356 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.connected",
357 | "group": "primary@1"
358 | },
359 | {
360 | "command": "ev3devBrowser.deviceTreeItem.captureScreenshot",
361 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.connected",
362 | "group": "primary@2"
363 | },
364 | {
365 | "command": "ev3devBrowser.deviceTreeItem.showSysinfo",
366 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.connected",
367 | "group": "primary@3"
368 | },
369 | {
370 | "command": "ev3devBrowser.fileTreeItem.run",
371 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable",
372 | "group": "group@1"
373 | },
374 | {
375 | "command": "ev3devBrowser.fileTreeItem.runInTerminal",
376 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable",
377 | "group": "group@1"
378 | },
379 | {
380 | "command": "ev3devBrowser.fileTreeItem.delete",
381 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file",
382 | "group": "group@5"
383 | },
384 | {
385 | "command": "ev3devBrowser.fileTreeItem.delete",
386 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable",
387 | "group": "group@5"
388 | },
389 | {
390 | "command": "ev3devBrowser.fileTreeItem.delete",
391 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.folder",
392 | "group": "group@5"
393 | },
394 | {
395 | "command": "ev3devBrowser.fileTreeItem.upload",
396 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable",
397 | "group": "group@9"
398 | },
399 | {
400 | "command": "ev3devBrowser.fileTreeItem.upload",
401 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file",
402 | "group": "group@9"
403 | },
404 | {
405 | "command": "ev3devBrowser.fileTreeItem.showInfo",
406 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable",
407 | "group": "group@10"
408 | },
409 | {
410 | "command": "ev3devBrowser.fileTreeItem.showInfo",
411 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file",
412 | "group": "group@10"
413 | }
414 | ]
415 | },
416 | "views": {
417 | "explorer": [
418 | {
419 | "id": "ev3devBrowser",
420 | "name": "ev3dev device browser"
421 | }
422 | ]
423 | }
424 | },
425 | "scripts": {
426 | "vscode:prepublish": "npm run esbuild-base -- --minify",
427 | "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/main.js --external:vscode --format=cjs --platform=node",
428 | "esbuild": "npm run esbuild-base -- --sourcemap",
429 | "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch",
430 | "compile": "tsc -p ./",
431 | "watch": "tsc -watch -p ./",
432 | "test": "npm run compile && node ./node_modules/vscode/bin/test"
433 | },
434 | "devDependencies": {
435 | "@types/bonjour": "^3.5.4",
436 | "@types/compare-versions": "^3.0.0",
437 | "@types/mocha": "^2.2.42",
438 | "@types/node": "^10.0.0",
439 | "@types/ssh2": "~0.5.35",
440 | "@types/ssh2-streams": "~0.1.5",
441 | "@types/temp": "^0.8.3",
442 | "@types/vscode": "^1.39.0",
443 | "@types/zen-observable": "^0.5.3",
444 | "esbuild": "^0.17.14",
445 | "tslint": "^5.8.0",
446 | "typescript": "^3.7.4",
447 | "vscode-test": "^1.3.0"
448 | },
449 | "dependencies": {
450 | "bonjour": "^3.5.0",
451 | "compare-versions": "^3.0.1",
452 | "dbus-next": "~0.8.2",
453 | "ssh2": "~0.5.5",
454 | "ssh2-streams": "~0.1.19",
455 | "temp": "^0.8.3",
456 | "vscode-debugadapter": "^1.37.1",
457 | "zen-observable": "^0.5.2"
458 | }
459 | }
--------------------------------------------------------------------------------
/src/device.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import * as os from 'os';
3 | import * as readline from 'readline';
4 | import * as ssh2 from 'ssh2';
5 | import * as ssh2Streams from 'ssh2-streams';
6 | import * as vscode from 'vscode';
7 | import Observable from 'zen-observable';
8 |
9 | import { Brickd } from './brickd';
10 | import * as dnssd from './dnssd';
11 |
12 | /**
13 | * Object that represents a remote ev3dev device.
14 | */
15 | export class Device extends vscode.Disposable {
16 | private readonly client: ssh2.Client;
17 | private sftp?: ssh2.SFTPWrapper;
18 | private _homeDirectoryAttr?: ssh2Streams.Attributes;
19 | private _isConnecting = false;
20 | private _isConnected = false;
21 |
22 | /**
23 | * The username requested by the device.
24 | *
25 | * This value comes from a mDNS text record.
26 | */
27 | public readonly username: string;
28 |
29 | private readonly _onWillConnect = new vscode.EventEmitter();
30 | /**
31 | * Event that fires when a connection is initiated.
32 | *
33 | * This will be followed by either onDidConnect or onDidDisconnect.
34 | */
35 | public readonly onWillConnect = this._onWillConnect.event;
36 |
37 | private readonly _onDidConnect = new vscode.EventEmitter();
38 | /**
39 | * Event that fires when a connection has completed successfully.
40 | */
41 | public readonly onDidConnect = this._onDidConnect.event;
42 |
43 | private readonly _onDidDisconnect = new vscode.EventEmitter();
44 | /**
45 | * Event that fires when a connection has been closed.
46 | */
47 | public readonly onDidDisconnect = this._onDidDisconnect.event;
48 |
49 | constructor(private readonly service: dnssd.Service) {
50 | super(() => {
51 | this.disconnect();
52 | this._onWillConnect.dispose();
53 | this._onDidConnect.dispose();
54 | this._onDidDisconnect.dispose();
55 | this.client.destroy();
56 | });
57 | this.username = service.txt['ev3dev.robot.user'];
58 | this.client = new ssh2.Client();
59 | this.client.on('end', () => {
60 |
61 | });
62 | this.client.on('close', () => {
63 | this.disconnect();
64 | });
65 | this.client.on('keyboard-interactive', async (name, instructions, lang, prompts, finish) => {
66 | const answers = new Array();
67 | for (const p of prompts) {
68 | const choice = await vscode.window.showInputBox({
69 | ignoreFocusOut: true,
70 | password: !p.echo,
71 | prompt: p.prompt
72 | });
73 | // FIXME: how to cancel properly?
74 | answers.push(choice || '');
75 | }
76 | finish(answers);
77 | });
78 | }
79 |
80 | /**
81 | * Connect to the device using SSH.
82 | */
83 | public async connect(): Promise {
84 | this._isConnecting = true;
85 | this._onWillConnect.fire();
86 | await this.connectClient();
87 | try {
88 | this.sftp = await this.getSftp();
89 | this._homeDirectoryAttr = await this.stat(this.homeDirectoryPath);
90 | this._isConnecting = false;
91 | this._isConnected = true;
92 | this._onDidConnect.fire();
93 | }
94 | catch (err) {
95 | this._isConnecting = false;
96 | this.disconnect();
97 | throw err;
98 | }
99 | }
100 |
101 | private connectClient(): Promise {
102 | return new Promise((resolve, reject) => {
103 | this.client.once('ready', resolve);
104 | this.client.once('error', reject);
105 | let address = this.service.address;
106 | if (this.service.ipv === 'IPv6' && address.startsWith('fe80::')) {
107 | // this is IPv6 link local address, so we need to add the network
108 | // interface to the end
109 | if (process.platform === 'win32') {
110 | // Windows uses the interface index
111 | address += `%${this.service.iface}`;
112 | }
113 | else {
114 | // everyone else uses the interface name
115 | address += `%${(this.service)['ifaceName']}`;
116 | }
117 | }
118 | const config = vscode.workspace.getConfiguration('ev3devBrowser');
119 | this.client.connect({
120 | host: address,
121 | username: this.username,
122 | password: config.get('password'),
123 | tryKeyboard: true,
124 | keepaliveCountMax: 5,
125 | keepaliveInterval: 1000,
126 | readyTimeout: config.get('connectTimeout', 30) * 1000,
127 | });
128 | });
129 | }
130 |
131 | private getSftp(): Promise {
132 | return new Promise((resolve, reject) => {
133 | // This can keep the connection busy for a long time. On Bluetooth,
134 | // it is enough for the keepalive timeout to expire. So, we ignore
135 | // the keepalive during this operation.
136 | const timer = setInterval(() => {
137 | (this.client)._resetKA();
138 | }, 1000);
139 | this.client.sftp((err, sftp) => {
140 | clearInterval(timer);
141 | if (err) {
142 | reject(err);
143 | return;
144 | }
145 | resolve(sftp);
146 | });
147 | });
148 | }
149 |
150 | /**
151 | * Disconnect from the device.
152 | */
153 | public disconnect(): void {
154 | this._isConnected = false;
155 | if (this.sftp) {
156 | this.sftp.end();
157 | this.sftp = undefined;
158 | }
159 | this.client.end();
160 | this._onDidDisconnect.fire();
161 | }
162 |
163 | /**
164 | * Tests if a connection is currently in progress.
165 | */
166 | public get isConnecting(): boolean {
167 | return this._isConnecting;
168 | }
169 |
170 | /**
171 | * Tests if a device is currently connected.
172 | */
173 | public get isConnected(): boolean {
174 | return this._isConnected;
175 | }
176 |
177 | /**
178 | * Gets the name of the device.
179 | */
180 | public get name(): string {
181 | return this.service.name;
182 | }
183 |
184 | /**
185 | * Get the file attributes of the home directory.
186 | */
187 | public get homeDirectoryAttr(): ssh2Streams.Attributes {
188 | if (!this._homeDirectoryAttr) {
189 | throw new Error('Not connected');
190 | }
191 | return this._homeDirectoryAttr;
192 | }
193 |
194 | /**
195 | * Gets the home directory path for the device.
196 | */
197 | public get homeDirectoryPath(): string {
198 | return this.service.txt['ev3dev.robot.home'] || `/home/${this.username}`;
199 | }
200 |
201 | /**
202 | * Sets file permissions.
203 | * @param path The path to a file or directory
204 | * @param mode The file permissions
205 | */
206 | public chmod(path: string, mode: string | number): Promise {
207 | return new Promise((resolve, reject) => {
208 | if (!this.sftp) {
209 | reject(new Error('Not connected'));
210 | return;
211 | }
212 | this.sftp.chmod(path, mode, err => {
213 | if (err) {
214 | reject(err);
215 | }
216 | else {
217 | resolve();
218 | }
219 | });
220 | });
221 | }
222 |
223 | /**
224 | * Executes a command on the remote device.
225 | * @param command The absolute path of the command.
226 | */
227 | public exec(command: string, env?: any, pty?: ssh2.PseudoTtyOptions): Promise {
228 | return new Promise((resolve, reject) => {
229 | const options = {
230 | env: env,
231 | pty: pty,
232 | };
233 | this.client.exec(command, options, (err, channel) => {
234 | if (err) {
235 | reject(err);
236 | return;
237 | }
238 | resolve(channel);
239 | });
240 | });
241 | }
242 |
243 | /**
244 | * Create an observable that monitors the stdout and stderr of a command.
245 | * @param command The command to execute.
246 | */
247 | public async createExecObservable(command: string): Promise<[Observable, Observable]> {
248 | return new Promise<[Observable, Observable]>(async (resolve, reject) => {
249 | try {
250 | const conn = await this.exec(command);
251 | const stdout = new Observable(observer => {
252 | readline.createInterface({
253 | input: conn.stdout
254 | }).on('line', line => {
255 | observer.next(line);
256 | }).on('close', () => {
257 | observer.complete();
258 | });
259 | });
260 | const stderr = new Observable(observer => {
261 | readline.createInterface({
262 | input: conn.stderr
263 | }).on('line', line => {
264 | observer.next(line);
265 | }).on('close', () => {
266 | observer.complete();
267 | });
268 | });
269 | resolve([stdout, stderr]);
270 | }
271 | catch (err) {
272 | reject(err);
273 | }
274 | });
275 | }
276 |
277 | /**
278 | * Starts a new shell on the remote device.
279 | * @param window Optional pty settings or false to not allocate a pty.
280 | */
281 | public shell(window: false | ssh2.PseudoTtyOptions): Promise {
282 | return new Promise((resolve, reject) => {
283 | const options = {
284 | env: vscode.workspace.getConfiguration('ev3devBrowser').get('env')
285 | };
286 | this.client.shell(window, options, (err, stream) => {
287 | if (err) {
288 | reject(err);
289 | }
290 | else {
291 | resolve(stream);
292 | }
293 | });
294 | });
295 | }
296 |
297 | /**
298 | * Create a directory.
299 | * @param path the path of the directory.
300 | */
301 | public mkdir(path: string): Promise {
302 | return new Promise((resolve, reject) => {
303 | if (!this.sftp) {
304 | reject(new Error('Not connected'));
305 | return;
306 | }
307 | this.sftp.mkdir(path, err => {
308 | if (err) {
309 | reject(err);
310 | }
311 | else {
312 | resolve();
313 | }
314 | });
315 | });
316 | }
317 |
318 | /**
319 | * Recursively create a directory (equivalent of mkdir -p).
320 | * @param dirPath the path of the directory
321 | */
322 | public async mkdir_p(dirPath: string): Promise {
323 | if (!path.posix.isAbsolute(dirPath)) {
324 | throw new Error("The supplied file path must be absolute.");
325 | }
326 |
327 | const names = dirPath.split('/');
328 |
329 | // Leading slash produces empty first element
330 | names.shift();
331 |
332 | let part = '/';
333 | while (names.length) {
334 | part = path.posix.join(part, names.shift());
335 | // Create the directory if it doesn't already exist
336 | try {
337 | const stat = await this.stat(part);
338 | if (!stat.isDirectory()) {
339 | throw new Error(`Cannot create directory: "${part}" exists but isn't a directory`);
340 | }
341 | }
342 | catch (err) {
343 | if (err.code !== ssh2.SFTP_STATUS_CODE.NO_SUCH_FILE) {
344 | throw err;
345 | }
346 | await this.mkdir(part);
347 | }
348 | }
349 | }
350 |
351 | /**
352 | * Copy a remote file to the local host.
353 | * @param remote The remote path.
354 | * @param local The path where the file will be saved.
355 | * @param reportPercentage An optional progress reporting callback
356 | */
357 | public get(remote: string, local: string, reportPercentage?: (percentage: number) => void): Promise {
358 | return new Promise((resolve, reject) => {
359 | if (!this.sftp) {
360 | reject(new Error('Not connected'));
361 | return;
362 | }
363 | this.sftp.fastGet(remote, local, {
364 | concurrency: 1,
365 | step: (transferred, chunk, total) => {
366 | if (reportPercentage) {
367 | reportPercentage(Math.round(transferred / total * 100));
368 | }
369 | },
370 | }, err => {
371 | if (err) {
372 | reject(err);
373 | }
374 | else {
375 | resolve();
376 | }
377 | });
378 | });
379 | }
380 |
381 | /**
382 | * Copy a local file to the remote device.
383 | * @param local The path to a local file.
384 | * @param remote The remote path where the file will be saved.
385 | * @param mode The file permissions
386 | * @param reportPercentage An optional progress reporting callback
387 | */
388 | public put(local: string, remote: string, mode?: string, reportPercentage?: (percentage: number) => void): Promise {
389 | return new Promise((resolve, reject) => {
390 | if (!this.sftp) {
391 | reject(new Error('Not connected'));
392 | return;
393 | }
394 | this.sftp.fastPut(local, remote, {
395 | concurrency: 1,
396 | step: (transferred, chunk, total) => {
397 | if (reportPercentage) {
398 | reportPercentage(Math.round(transferred / total * 100));
399 | }
400 | },
401 | mode: mode
402 | }, (err) => {
403 | if (err) {
404 | reject(err);
405 | }
406 | else {
407 | resolve();
408 | }
409 | });
410 | });
411 | }
412 |
413 | /**
414 | * List the contents of a remote directory.
415 | * @param path The path to a directory.
416 | */
417 | public ls(path: string): Promise {
418 | return new Promise((resolve, reject) => {
419 | if (!this.sftp) {
420 | reject(new Error('Not connected'));
421 | return;
422 | }
423 | this.sftp.readdir(path, (err, list) => {
424 | if (err) {
425 | reject(err);
426 | }
427 | else {
428 | resolve(list);
429 | }
430 | });
431 | });
432 | }
433 |
434 | /**
435 | * Stat a remote file or directory.
436 | * @param path The path to a remote file or directory.
437 | */
438 | public stat(path: string): Promise {
439 | return new Promise((resolve, reject) => {
440 | if (!this.sftp) {
441 | reject(new Error('Not connected'));
442 | return;
443 | }
444 | this.sftp.stat(path, (err, stats) => {
445 | if (err) {
446 | reject(err);
447 | }
448 | else {
449 | resolve(stats);
450 | }
451 | });
452 | });
453 | }
454 |
455 | /**
456 | * Remove a remote file.
457 | * @param path The path to a file or symlink to remove (unlink)
458 | */
459 | public rm(path: string): Promise {
460 | return new Promise((resolve, reject) => {
461 | if (!this.sftp) {
462 | reject(new Error('Not connected'));
463 | return;
464 | }
465 | this.sftp.unlink(path, err => {
466 | if (err) {
467 | reject(err);
468 | }
469 | else {
470 | resolve();
471 | }
472 | });
473 | });
474 | }
475 |
476 | public async rm_rf(path: string): Promise {
477 | const stat = await this.stat(path);
478 | if (stat.isDirectory()) {
479 | for (const f of await this.ls(path)) {
480 | await this.rm_rf(`${path}/${f.filename}`);
481 | }
482 | await this.rmdir(path);
483 | }
484 | else {
485 | await this.rm(path);
486 | }
487 | }
488 |
489 | public rmdir(path: string): Promise {
490 | return new Promise((resolve, reject) => {
491 | if (!this.sftp) {
492 | reject(new Error('Not connected'));
493 | return;
494 | }
495 | this.sftp.rmdir(path, err => {
496 | if (err) {
497 | reject(err);
498 | }
499 | else {
500 | resolve();
501 | }
502 | });
503 | });
504 | }
505 |
506 | private static dnssdClient: dnssd.Client;
507 | private static async getDnssdClient(): Promise {
508 | if (!Device.dnssdClient) {
509 | Device.dnssdClient = await dnssd.getInstance();
510 | }
511 | return Device.dnssdClient;
512 | }
513 |
514 | private static additionalDeviceToDnssdService(device: AdditionalDevice): dnssd.Service {
515 | const txt: dnssd.TxtRecords = {};
516 | txt['ev3dev.robot.user'] = device.username || 'robot';
517 | txt['ev3dev.robot.home'] = device.homeDirectory || `/home/${txt['ev3dev.robot.user']}`;
518 |
519 | // device.ipAddress is validated, so this is safe
520 | const [address, port] = device.ipAddress.split(':');
521 |
522 | return {
523 | name: device.name,
524 | address,
525 | ipv: 'IPv4',
526 | port: Number(port) || 22,
527 | service: 'sftp-ssh',
528 | transport: 'tcp',
529 | txt: txt
530 | };
531 | }
532 |
533 | /**
534 | * Read additional device definitions from the config and convert them to
535 | * ServiceItems
536 | */
537 | private static getServicesFromConfig(): ServiceItem[] {
538 | const services = new Array();
539 | const devices = vscode.workspace.getConfiguration('ev3devBrowser').get('additionalDevices', []);
540 | for (const device of devices) {
541 | services.push({
542 | label: device.name,
543 | service: this.additionalDeviceToDnssdService(device)
544 | });
545 | }
546 | return services;
547 | }
548 |
549 | /**
550 | * Use a quick-pick to browse discovered devices and select one.
551 | * @returns A new Device or undefined if the user canceled the request
552 | */
553 | public static async pickDevice(): Promise {
554 | const configItems = this.getServicesFromConfig();
555 | const manualEntry = {
556 | label: "I don't see my device..."
557 | };
558 |
559 | const selectedItem = await new Promise(async (resolve, reject) => {
560 | // start browsing for devices
561 | const dnssdClient = await Device.getDnssdClient();
562 | const browser = await dnssdClient.createBrowser({
563 | ipv: 'IPv6',
564 | service: 'sftp-ssh'
565 | });
566 | const items = new Array();
567 | let cancelSource: vscode.CancellationTokenSource | undefined;
568 | let done = false;
569 |
570 | // if a device is added or removed, cancel the quick-pick
571 | // and then show a new one with the update list
572 | browser.on('added', (service) => {
573 | if (service.txt['ev3dev.robot.home']) {
574 | // this looks like an ev3dev device
575 | const ifaces = os.networkInterfaces();
576 | for (const ifaceName in ifaces) {
577 | if (ifaces[ifaceName].find(v => (v).scopeid === service.iface)) {
578 | (service)['ifaceName'] = ifaceName;
579 | break;
580 | }
581 | }
582 | const item = new ServiceItem(service);
583 | items.push(item);
584 | cancelSource?.cancel();
585 | }
586 | });
587 | browser.on('removed', (service) => {
588 | const index = items.findIndex(si => si.service === service);
589 | if (index > -1) {
590 | items.splice(index, 1);
591 | cancelSource?.cancel();
592 | }
593 | });
594 |
595 | // if there is a browser error, cancel the quick-pick and show
596 | // an error message
597 | browser.on('error', err => {
598 | cancelSource?.cancel();
599 | browser.destroy();
600 | done = true;
601 | reject(err);
602 | });
603 |
604 | await browser.start();
605 |
606 | while (!done) {
607 | cancelSource = new vscode.CancellationTokenSource();
608 | // using this promise in the quick-pick will cause a progress
609 | // bar to show if there are no items.
610 | const list = new Array();
611 | if (items) {
612 | list.push(...items);
613 | }
614 | if (configItems) {
615 | list.push(...configItems);
616 | }
617 | list.push(manualEntry);
618 | const selected = await vscode.window.showQuickPick(list, {
619 | ignoreFocusOut: true,
620 | placeHolder: "Searching for devices... Select a device or press ESC to cancel."
621 | }, cancelSource.token);
622 | if (cancelSource.token.isCancellationRequested) {
623 | continue;
624 | }
625 | browser.destroy();
626 | done = true;
627 | resolve(selected);
628 | }
629 | });
630 | if (!selectedItem) {
631 | // cancelled
632 | return undefined;
633 | }
634 |
635 | if (selectedItem === manualEntry) {
636 | const name = await vscode.window.showInputBox({
637 | ignoreFocusOut: true,
638 | prompt: "Enter a name for the device",
639 | placeHolder: 'Example: "ev3dev (Bluetooth)"'
640 | });
641 | if (!name) {
642 | // cancelled
643 | return undefined;
644 | }
645 | const ipAddress = await vscode.window.showInputBox({
646 | ignoreFocusOut: true,
647 | prompt: "Enter the IP address of the device",
648 | placeHolder: 'Example: "192.168.137.3"',
649 | validateInput: (v) => {
650 | if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$/.test(v)) {
651 | return 'Not a valid IP address';
652 | }
653 | return undefined;
654 | }
655 | });
656 | if (!ipAddress) {
657 | // cancelled
658 | return undefined;
659 | }
660 |
661 | const device = {
662 | name: name,
663 | ipAddress: ipAddress
664 | };
665 |
666 | const config = vscode.workspace.getConfiguration('ev3devBrowser');
667 | const existing = config.get('additionalDevices', []);
668 | existing.push(device);
669 | config.update('additionalDevices', existing, vscode.ConfigurationTarget.Global);
670 |
671 | return new Device(this.additionalDeviceToDnssdService(device));
672 | }
673 |
674 | return new Device(selectedItem.service);
675 | }
676 |
677 | private async forwardOut(srcAddr: string, srcPort: number, destAddr: string, destPort: number): Promise {
678 | return new Promise((resolve, reject) => {
679 | this.client.forwardOut(srcAddr, srcPort, destAddr, destPort, (err, channel) => {
680 | if (err) {
681 | reject(err);
682 | }
683 | else {
684 | resolve(channel);
685 | }
686 | });
687 | });
688 | }
689 |
690 | /**
691 | * Gets a new connection to brickd.
692 | *
693 | * @returns A promise of a Brickd object.
694 | */
695 | public async brickd(): Promise {
696 | const channel = await this.forwardOut('localhost', 0, 'localhost', 31313);
697 | return new Brickd(channel);
698 | }
699 | }
700 |
701 | /**
702 | * Quick pick item used in DeviceManager.pickDevice().
703 | */
704 | class ServiceItem implements vscode.QuickPickItem {
705 | public readonly label: string;
706 | public readonly description: string | undefined;
707 |
708 | constructor(public service: dnssd.Service) {
709 | this.label = service.name;
710 | this.description = (service)['ifaceName'];
711 | }
712 | }
713 |
714 | interface AdditionalDevice {
715 | name: string;
716 | ipAddress: string;
717 | username: string;
718 | homeDirectory: string;
719 | }
720 |
--------------------------------------------------------------------------------
/src/extension.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import * as net from 'net';
3 | import * as os from 'os';
4 | import * as path from 'path';
5 | import * as ssh2Streams from 'ssh2-streams';
6 | import * as temp from 'temp';
7 |
8 | import * as vscode from 'vscode';
9 |
10 | import { Ev3devBrowserDebugSession, LaunchRequestArguments } from './debugServer';
11 | import { Brickd } from './brickd';
12 | import { Device } from './device';
13 | import {
14 | getSharedTempDir,
15 | sanitizedDateString,
16 | setContext,
17 | toastStatusBarMessage,
18 | verifyFileHeader,
19 | getPlatform,
20 | normalizeLineEndings,
21 | } from './utils';
22 |
23 | // fs.constants.S_IXUSR is undefined on win32!
24 | const S_IXUSR = 0o0100;
25 |
26 | let config: WorkspaceConfig;
27 | let output: vscode.OutputChannel;
28 | let resourceDir: string;
29 | let ev3devBrowserProvider: Ev3devBrowserProvider;
30 |
31 | // this method is called when your extension is activated
32 | // your extension is activated the very first time the command is executed
33 | export function activate(context: vscode.ExtensionContext): void {
34 | config = new WorkspaceConfig(context.workspaceState);
35 | output = vscode.window.createOutputChannel('ev3dev');
36 | resourceDir = context.asAbsolutePath('resources');
37 |
38 | ev3devBrowserProvider = new Ev3devBrowserProvider();
39 | const factory = new Ev3devDebugAdapterDescriptorFactory();
40 | const provider = new Ev3devDebugConfigurationProvider();
41 | context.subscriptions.push(
42 | output, ev3devBrowserProvider,
43 | vscode.window.registerTreeDataProvider('ev3devBrowser', ev3devBrowserProvider),
44 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.openSshTerminal', d => d.openSshTerminal()),
45 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.captureScreenshot', d => d.captureScreenshot()),
46 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.showSysinfo', d => d.showSysinfo()),
47 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.reconnect', d => d.connect()),
48 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.connectNew', d => pickDevice()),
49 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.disconnect', d => d.disconnect()),
50 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.select', d => d.handleClick()),
51 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.run', f => f.run()),
52 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.runInTerminal', f => f.runInTerminal()),
53 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.delete', f => f.delete()),
54 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.showInfo', f => f.showInfo()),
55 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.upload', f => f.upload()),
56 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.select', f => f.handleClick()),
57 | vscode.commands.registerCommand('ev3devBrowser.action.pickDevice', () => pickDevice()),
58 | vscode.commands.registerCommand('ev3devBrowser.action.download', () => downloadAll()),
59 | vscode.commands.registerCommand('ev3devBrowser.action.refresh', () => refresh()),
60 | vscode.debug.onDidReceiveDebugSessionCustomEvent(e => handleCustomDebugEvent(e)),
61 | vscode.debug.registerDebugAdapterDescriptorFactory('ev3devBrowser', factory),
62 | vscode.debug.registerDebugConfigurationProvider('ev3devBrowser', provider),
63 | );
64 | }
65 |
66 | class Ev3devDebugAdapterDescriptorFactory implements vscode.DebugAdapterDescriptorFactory {
67 | private server?: net.Server;
68 |
69 | createDebugAdapterDescriptor(session: vscode.DebugSession, executable: vscode.DebugAdapterExecutable | undefined): vscode.ProviderResult {
70 | if (!this.server) {
71 | // start listening on a random port
72 | this.server = net.createServer(socket => {
73 | const session = new Ev3devBrowserDebugSession();
74 | session.setRunAsServer(true);
75 | session.start(socket, socket);
76 | }).listen(0);
77 | }
78 |
79 | // make VS Code connect to debug server
80 | return new vscode.DebugAdapterServer((this.server.address()).port);
81 | }
82 |
83 | dispose() {
84 | this.server?.close();
85 | }
86 | }
87 |
88 | class Ev3devDebugConfigurationProvider implements vscode.DebugConfigurationProvider {
89 | async resolveDebugConfiguration(
90 | _folder: vscode.WorkspaceFolder | undefined,
91 | debugConfiguration: vscode.DebugConfiguration,
92 | token?: vscode.CancellationToken,
93 | ): Promise {
94 | if (Object.keys(debugConfiguration).length === 0) {
95 | type DebugConfigurationQuickPickItem = vscode.QuickPickItem & { interactiveTerminal: boolean };
96 | const items: DebugConfigurationQuickPickItem[] = [
97 | {
98 | label: "Download and run current file",
99 | description: "in interactive terminal",
100 | interactiveTerminal: true,
101 | },
102 | {
103 | label: "Download and run current file",
104 | description: "in output pane",
105 | interactiveTerminal: false,
106 | },
107 | ];
108 | const selected = await vscode.window.showQuickPick(items, {
109 | matchOnDescription: true,
110 | ignoreFocusOut: true,
111 | placeHolder: "Debug configuration"
112 | }, token);
113 | if (selected) {
114 | return {
115 | type: "ev3devBrowser",
116 | name: `${selected.label} ${selected.description}`,
117 | request: "launch",
118 | program: "/home/robot/${workspaceFolderBasename}/${relativeFile}",
119 | interactiveTerminal: selected.interactiveTerminal
120 | };
121 | }
122 | }
123 | return debugConfiguration;
124 | }
125 | }
126 |
127 | // this method is called when your extension is deactivated
128 | export function deactivate(): void {
129 | // The "temp" module should clean up automatically, but do this just in case.
130 | temp.cleanupSync();
131 | }
132 |
133 | async function pickDevice(): Promise {
134 | const device = await Device.pickDevice();
135 | if (!device) {
136 | // user canceled
137 | return;
138 | }
139 |
140 | await vscode.window.withProgress({
141 | location: vscode.ProgressLocation.Window,
142 | title: "Connecting..."
143 | }, async progress => {
144 | ev3devBrowserProvider.setDevice(device);
145 | try {
146 | await device.connect();
147 | toastStatusBarMessage(`Connected`);
148 | }
149 | catch (err) {
150 | const troubleshoot = 'Troubleshoot';
151 | vscode.window.showErrorMessage(`Failed to connect to ${device.name}: ${err.message}`, troubleshoot)
152 | .then((value) => {
153 | if (value === troubleshoot) {
154 | const wiki = vscode.Uri.parse('https://github.com/ev3dev/vscode-ev3dev-browser/wiki/Troubleshooting')
155 | vscode.commands.executeCommand('vscode.open', wiki);
156 | }
157 | });
158 | }
159 | });
160 | }
161 |
162 | const activeDebugSessions = new Set();
163 | let debugTerminal: vscode.Terminal;
164 | let debugRestarting: boolean;
165 |
166 | async function handleCustomDebugEvent(event: vscode.DebugSessionCustomEvent): Promise {
167 | let device: Device | undefined;
168 | switch (event.event) {
169 | case 'ev3devBrowser.debugger.launch':
170 | const args = event.body;
171 | device = await ev3devBrowserProvider.getDevice();
172 | if (device && !device.isConnected) {
173 | const item = ev3devBrowserProvider.getDeviceTreeItem();
174 | if (item) {
175 | await item.connect();
176 | }
177 | }
178 | if (!device || !device.isConnected) {
179 | await event.session.customRequest('ev3devBrowser.debugger.terminate');
180 | break;
181 | }
182 |
183 | // optionally download before running - workspaceFolder can be undefined
184 | // if the request did not come from a specific project, in which case we
185 | // download all projects
186 | const folder = event.session.workspaceFolder;
187 | if (args.download !== false && !(folder ? await download(folder, device) : await downloadAll())) {
188 | // download() shows error messages, so don't show additional message here.
189 | await event.session.customRequest('ev3devBrowser.debugger.terminate');
190 | break;
191 | }
192 |
193 | // run the program
194 | try {
195 | // normalize the path to unix path separators since this path will be used on the EV3
196 | const programPath = vscode.Uri.file(args.program).path;
197 |
198 | const dirname = path.posix.dirname(programPath);
199 | if (args.interactiveTerminal) {
200 | const command = `brickrun -r --directory="${dirname}" "${programPath}"`;
201 | const config = vscode.workspace.getConfiguration(`terminal.integrated.env.${getPlatform()}`);
202 | const termEnv = config.get('TERM');
203 | const env = {
204 | ...vscode.workspace.getConfiguration('ev3devBrowser').get