├── .gitignore ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── src ├── utils │ └── stream.ts ├── pathquickpick.ts ├── devicesprovider.ts ├── extension.ts ├── localize.ts ├── adb.ts └── fsprovider.ts ├── .vscodeignore ├── .eslintrc.json ├── assets └── adb.svg ├── tsconfig.json ├── package.nls.zh.json ├── README.md ├── LICENSE ├── package.nls.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | package-lock.json 7 | -------------------------------------------------------------------------------- /.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 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/stream.ts: -------------------------------------------------------------------------------- 1 | import { Readable, Duplex } from 'stream'; 2 | 3 | export function toReadable(data: Uint8Array): Readable { 4 | const stream = new Duplex(); 5 | stream.push(data); 6 | stream.push(null); 7 | return stream; 8 | } 9 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | node_modules 4 | build/** 5 | out/test/** 6 | src/** 7 | .gitignore 8 | .yarnrc 9 | vsc-extension-quickstart.md 10 | **/tsconfig.json 11 | **/.eslintrc.json 12 | **/*.map 13 | **/*.ts 14 | package-lock.json 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/semi": "warn", 13 | "curly": "warn", 14 | "eqeqeq": "warn", 15 | "no-throw-literal": "warn", 16 | "semi": "off" 17 | } 18 | } 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": "webpack", 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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /assets/adb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.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 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/dist/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true /* enable all strict type-checking options */ 12 | /* Additional Checks */ 13 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 14 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 15 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | ".vscode-test", 20 | "dist", 21 | "out" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /package.nls.zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "gwo.android.extension.name": "Gwo 安卓助手", 3 | "gwo.android.extension.description": "通过 Visual Studio Code 在你的 Android 设备上浏览文件", 4 | "gwo.android.devices.viewTitle": "设备", 5 | "gwo.android.devices.refresh": "刷新", 6 | "gwo.android.devices.openInternalStorage": "打开内置存储", 7 | "gwo.android.devices.openFolder": "打开文件夹...", 8 | "gwo.android.devices.openTerminal": "在此设备上打开终端", 9 | "gwo.android.devices.welcome": "没有找到设备。", 10 | "gwo.android.devices.chooseFolderTitle": "在 {0} 上选择文件夹", 11 | "gwo.android.folder.openMessage": "\"{0}\" 已经在当前工作区打开了,切换到文件管理栏就可以看到了。", 12 | "gwo.android.folder.internalStorageTitle": "内置 - {0}", 13 | "gwo.android.terminal.title": "Adb 终端 - {0}", 14 | "gwo.android.type.device": "在线", 15 | "gwo.android.type.offline": "离线", 16 | "gwo.android.type.recovery": "恢复模式", 17 | "gwo.android.type.fastboot": "刷机模式", 18 | "gwo.android.type.sideload": "旁加载", 19 | "gwo.android.type.unauthorized": "未授权" 20 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gwo Android Helper 2 | ====== 3 | 4 | Browse files on your Android devices with Visual Studio Code 5 | 6 | ## Requirements 7 | 8 | - Visual Studio Code 1.53+ 9 | - Android Debug Bridge (should be in `$PATH`) 10 | 11 | # What can it do? 12 | 13 | - Operate files on your Android devices in file manager tab just like your local files. 14 | - Open adb shell in terminal tab 15 | 16 | I develop this extension for managing my files between phone and computer. Without building a new program, using Visual Studio Code as a UI & file manager framework/platform saves a lot of time. So we didn't need to do much work for adapting project to different OS. 17 | 18 | ## Usage 19 | 20 | 1. Install extension from marketplace or compile by yourself: Gwo Android Helper 21 | 2. Click "Gwo Android Helper" tab in side bar 22 | 3. Find out your device and click "Open internal storage" (or choose other actions) 23 | 4. Switch back to "File explorer" tab and you can see device storage in current workspace 24 | 25 | ## License 26 | 27 | MIT License 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Siubeng (fython) 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. -------------------------------------------------------------------------------- /package.nls.json: -------------------------------------------------------------------------------- 1 | { 2 | "gwo.android.extension.name": "Gwo Android Helper", 3 | "gwo.android.extension.description": "Browse files on your Android devices with Visual Studio Code", 4 | "gwo.android.devices.viewTitle": "Devices", 5 | "gwo.android.devices.refresh": "Refresh", 6 | "gwo.android.devices.openInternalStorage": "Open internal stroage", 7 | "gwo.android.devices.openFolder": "Open folder...", 8 | "gwo.android.devices.openTerminal": "Terminal on this device", 9 | "gwo.android.devices.welcome": "No devices found.", 10 | "gwo.android.devices.chooseFolderTitle": "Choose folder on {0}", 11 | "gwo.android.folder.openMessage": "\"{0}\" is open in current workspace. Switch to file explorer tab then you can see it.", 12 | "gwo.android.folder.internalStorageTitle": "Internal - {0}", 13 | "gwo.android.terminal.title": "Adb Shell - {0}", 14 | "gwo.android.type.device": "Online", 15 | "gwo.android.type.offline": "Offline", 16 | "gwo.android.type.recovery": "Recovery", 17 | "gwo.android.type.fastboot": "Fastboot", 18 | "gwo.android.type.sideload": "Sideload", 19 | "gwo.android.type.unauthorized": "Unauthorized" 20 | } -------------------------------------------------------------------------------- /src/pathquickpick.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as adb from './adb'; 3 | import { AdbDeviceTreeItem } from './devicesprovider'; 4 | import localize from './localize'; 5 | 6 | export function bind(box: vscode.QuickPick, item: AdbDeviceTreeItem, cb: (path: string) => void) { 7 | box.title = localize('gwo.android.devices.chooseFolderTitle', item.label as string); 8 | box.value = '/'; 9 | box.canSelectMany = false; 10 | 11 | const loadAutoComplete = async (path: string) => { 12 | if (!path.startsWith('/')) { 13 | path = '/' + path; 14 | } 15 | box.busy = true; 16 | try { 17 | const dirs = (await adb.readDirectory(item.id, path)) 18 | .filter(entry => !entry.isFile()) 19 | .map(entry => path + (path.endsWith('/') ? '' : '/') + entry.name); 20 | box.items = [path, ...dirs].map(dir => new PathPickItem(dir)); 21 | } finally { 22 | box.busy = false; 23 | } 24 | }; 25 | 26 | box.onDidChangeValue(loadAutoComplete); 27 | box.onDidChangeSelection((entry: PathPickItem[]) => { 28 | if (entry.length === 1) { 29 | if (entry[0].label === box.value) { 30 | let targetPath = box.value; 31 | if (targetPath.endsWith('/') && targetPath !== '/') { 32 | targetPath = targetPath.substr(0, targetPath.length - 1); 33 | } 34 | cb(targetPath); 35 | box.dispose(); 36 | } 37 | box.value = entry[0].label + '/'; 38 | } 39 | loadAutoComplete(box.value); 40 | }); 41 | 42 | loadAutoComplete(box.value); 43 | } 44 | 45 | class PathPickItem implements vscode.QuickPickItem { 46 | constructor(public label: string) {} 47 | } 48 | -------------------------------------------------------------------------------- /src/devicesprovider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { AdbDevice } from './adb'; 3 | import * as adb from './adb'; 4 | import localize from './localize'; 5 | 6 | export class AdbDevicesProvider implements vscode.TreeDataProvider { 7 | private _onDidChangeTreeData: vscode.EventEmitter = 8 | new vscode.EventEmitter(); 9 | readonly onDidChangeTreeData: vscode.Event = 10 | this._onDidChangeTreeData.event; 11 | 12 | constructor() { 13 | setInterval(() => { 14 | this.refresh(); 15 | }, 1000 * 30); 16 | } 17 | 18 | refresh(): void { 19 | this._onDidChangeTreeData.fire(); 20 | } 21 | 22 | getTreeItem(element: AdbDeviceTreeItem): vscode.TreeItem { 23 | return element; 24 | } 25 | 26 | async getChildren(element?: AdbDeviceTreeItem): Promise { 27 | if (element) { 28 | return []; 29 | } else { 30 | const devices = await adb.listDevices(); 31 | return devices.map(device => new AdbDeviceTreeItem(device)); 32 | } 33 | } 34 | } 35 | 36 | function getIconNameByDeviceType(type: string): string { 37 | switch (type) { 38 | case 'offline': 39 | return 'warning'; 40 | default: 41 | return 'device-mobile'; 42 | } 43 | } 44 | 45 | function getLocalizedDeviceType(type: string): string { 46 | const result = localize('gwo.android.type.' + type); 47 | if (result.indexOf('%') >= 0) { 48 | return type; 49 | } 50 | return result; 51 | } 52 | 53 | export class AdbDeviceTreeItem extends vscode.TreeItem { 54 | public readonly id: string; 55 | public type: string; 56 | 57 | constructor(device: AdbDevice) { 58 | super(device.id, vscode.TreeItemCollapsibleState.None); 59 | if (device.name?.length ?? 0 > 0) { 60 | this.label = `${device.name} (${device.id})`; 61 | } 62 | this.id = device.id; 63 | this.type = device.type; 64 | this.contextValue = 'adbDevice'; 65 | this.description = getLocalizedDeviceType(device.type); 66 | this.tooltip = device.id; 67 | this.iconPath = new vscode.ThemeIcon(getIconNameByDeviceType(device.type)); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { AdbDevicesProvider, AdbDeviceTreeItem } from './devicesprovider'; 3 | import { AdbFileSystemProvider } from './fsprovider'; 4 | import * as PathQuickPick from './pathquickpick'; 5 | import localize from './localize'; 6 | 7 | export function activate(context: vscode.ExtensionContext) { 8 | const devicesProvider = new AdbDevicesProvider(); 9 | const adbFsProvider = new AdbFileSystemProvider(); 10 | adbFsProvider.isDebugging = true; 11 | 12 | const refreshAdbDevices = () => devicesProvider.refresh(); 13 | const openDeviceInternalStorage = (item: AdbDeviceTreeItem) => { 14 | console.log(`openDeviceInternalStorage: id=${item.id}`); 15 | vscode.workspace.updateWorkspaceFolders(0, null, { 16 | uri: vscode.Uri.parse(`adbfile:/${item.id}/internal`), 17 | name: localize('gwo.android.folder.internalStorageTitle', item.label as string), 18 | }); 19 | vscode.window.showInformationMessage( 20 | localize('gwo.android.folder.openMessage', item.label as string)); 21 | }; 22 | const openDeviceFolder = (item: AdbDeviceTreeItem) => { 23 | console.log(`openDeviceFolder: id=${item.id}`); 24 | const quickPick = vscode.window.createQuickPick(); 25 | PathQuickPick.bind(quickPick, item, (path: string) => { 26 | vscode.workspace.updateWorkspaceFolders(0, null, { 27 | uri: vscode.Uri.parse(`adbfile:/${item.id}/root${path}`), 28 | name: `${path} - ${item.label}`, 29 | }); 30 | vscode.window.showInformationMessage( 31 | localize('gwo.android.folder.openMessage', `${item.label}:${path}`)); 32 | }); 33 | quickPick.show(); 34 | }; 35 | const openTerminal = (item: AdbDeviceTreeItem) => { 36 | console.log(`openTerminal: id=${item.id}`); 37 | const terminal = vscode.window.createTerminal( 38 | localize('gwo.android.terminal.title', item.id), 39 | 'adb', ['-s', item.id, 'shell']); 40 | terminal.show(); 41 | }; 42 | 43 | context.subscriptions.push( 44 | vscode.workspace.registerFileSystemProvider('adbfile', adbFsProvider, { isCaseSensitive: true }), 45 | vscode.window.registerTreeDataProvider('adbDevices', devicesProvider), 46 | vscode.commands.registerCommand('adbDevices.refresh', refreshAdbDevices), 47 | vscode.commands.registerCommand('adbDevices.openDeviceInternalStorage', openDeviceInternalStorage), 48 | vscode.commands.registerCommand('adbDevices.openDeviceFolder', openDeviceFolder), 49 | vscode.commands.registerCommand('adbDevices.openTerminal', openTerminal), 50 | ); 51 | } 52 | 53 | export function deactivate() {} 54 | -------------------------------------------------------------------------------- /src/localize.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from 'fs'; 2 | import { resolve } from 'path'; 3 | import { extensions } from 'vscode'; 4 | 5 | const EXTENSION_ID = 'Siubeng.gwo-android-helper'; 6 | 7 | export interface ILanguagePack { 8 | [key: string]: string; 9 | } 10 | 11 | export class Localize { 12 | private bundle = this.resolveLanguagePack(); 13 | private options: { locale: string } = { locale: '' }; 14 | 15 | public localize(key: string, ...args: string[]): string { 16 | const message = this.bundle[key] || key; 17 | return this.format(message, args); 18 | } 19 | 20 | private init() { 21 | try { 22 | this.options = { 23 | ...this.options, 24 | ...JSON.parse(process.env.VSCODE_NLS_CONFIG || '{}') 25 | }; 26 | } catch (err) { 27 | throw err; 28 | } 29 | } 30 | 31 | private format(message: string, args: string[] = []): string { 32 | return args.length 33 | ? message.replace( 34 | /\{(\d+)\}/g, 35 | (match, rest: any[]) => args[rest[0]] || match 36 | ) 37 | : message; 38 | } 39 | 40 | private resolveLanguagePack(): ILanguagePack { 41 | this.init(); 42 | 43 | const languageFormat = 'package.nls{0}.json'; 44 | const defaultLanguage = languageFormat.replace('{0}', ''); 45 | 46 | const rootPath = extensions.getExtension(EXTENSION_ID)! 47 | .extensionPath; 48 | 49 | const resolvedLanguage = this.recurseCandidates( 50 | rootPath, 51 | languageFormat, 52 | this.options.locale 53 | ); 54 | 55 | const languageFilePath = resolve(rootPath, resolvedLanguage); 56 | 57 | try { 58 | const defaultLanguageBundle = JSON.parse( 59 | resolvedLanguage !== defaultLanguage 60 | ? readFileSync(resolve(rootPath, defaultLanguage), 'utf-8') 61 | : '{}' 62 | ); 63 | 64 | const resolvedLanguageBundle = JSON.parse( 65 | readFileSync(languageFilePath, 'utf-8') 66 | ); 67 | 68 | return { ...defaultLanguageBundle, ...resolvedLanguageBundle }; 69 | } catch (err) { 70 | throw err; 71 | } 72 | } 73 | 74 | private recurseCandidates( 75 | rootPath: string, 76 | format: string, 77 | candidate: string 78 | ): string { 79 | const filename = format.replace('{0}', `.${candidate}`); 80 | const filepath = resolve(rootPath, filename); 81 | if (existsSync(filepath)) { 82 | return filename; 83 | } 84 | if (candidate.split('-')[0] !== candidate) { 85 | return this.recurseCandidates(rootPath, format, candidate.split('-')[0]); 86 | } 87 | return format.replace('{0}', ''); 88 | } 89 | } 90 | 91 | export default Localize.prototype.localize.bind(new Localize()); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gwo-android-helper", 3 | "version": "0.0.2", 4 | "displayName": "%gwo.android.extension.name%", 5 | "description": "%gwo.android.extension.description%", 6 | "publisher": "Siubeng", 7 | "galleryBanner": { 8 | "color": "#c6f68d", 9 | "theme": "light" 10 | }, 11 | "license": "MIT", 12 | "homepage": "https://github.com/fython/vscode-gwo-android-helper#readme", 13 | "bugs": "https://github.com/fython/vscode-gwo-android-helper/issues", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/fython/vscode-gwo-android-helper.git" 17 | }, 18 | "engines": { 19 | "vscode": "^1.53.0" 20 | }, 21 | "categories": [ 22 | "Other" 23 | ], 24 | "keywords": [ 25 | "android", 26 | "adb", 27 | "helper" 28 | ], 29 | "author": { 30 | "name": "Siubeng", 31 | "email": "fythonx@gmail.com" 32 | }, 33 | "activationEvents": [ 34 | "*" 35 | ], 36 | "main": "./dist/extension.js", 37 | "contributes": { 38 | "commands": [ 39 | { 40 | "command": "adbDevices.refresh", 41 | "title": "%gwo.android.devices.refresh%", 42 | "icon": "$(extensions-refresh)" 43 | }, 44 | { 45 | "command": "adbDevices.openDeviceInternalStorage", 46 | "title": "%gwo.android.devices.openInternalStorage%", 47 | "icon": "$(extensions-remote)" 48 | }, 49 | { 50 | "command": "adbDevices.openDeviceFolder", 51 | "title": "%gwo.android.devices.openFolder%" 52 | }, 53 | { 54 | "command": "adbDevices.openTerminal", 55 | "title": "%gwo.android.devices.openTerminal%" 56 | } 57 | ], 58 | "menus": { 59 | "view/title": [ 60 | { 61 | "command": "adbDevices.refresh", 62 | "when": "view == adbDevices", 63 | "group": "navigation" 64 | } 65 | ], 66 | "view/item/context": [ 67 | { 68 | "command": "adbDevices.openDeviceInternalStorage", 69 | "when": "view == adbDevices && viewItem == adbDevice", 70 | "group": "inline" 71 | }, 72 | { 73 | "command": "adbDevices.openDeviceFolder", 74 | "when": "view == adbDevices && viewItem == adbDevice" 75 | }, 76 | { 77 | "command": "adbDevices.openTerminal", 78 | "when": "view == adbDevices && viewItem == adbDevice" 79 | } 80 | ] 81 | }, 82 | "viewsContainers": { 83 | "activitybar": [ 84 | { 85 | "id": "gwo-android-helper", 86 | "title": "%gwo.android.extension.name%", 87 | "icon": "assets/adb.svg" 88 | } 89 | ] 90 | }, 91 | "views": { 92 | "gwo-android-helper": [ 93 | { 94 | "id": "adbDevices", 95 | "name": "%gwo.android.devices.viewTitle%", 96 | "contextualTitle": "%gwo.android.extension.name%" 97 | } 98 | ] 99 | }, 100 | "viewsWelcome": [ 101 | { 102 | "view": "adbDevices", 103 | "contents": "%gwo.android.devices.welcome%" 104 | } 105 | ] 106 | }, 107 | "scripts": { 108 | "vscode:prepublish": "webpack --mode production --config ./build/webpack.config.js", 109 | "webpack": "webpack --mode development --config ./build/webpack.config.js", 110 | "webpack-dev": "webpack --mode development --watch --config ./build/webpack.config.js", 111 | "compile": "tsc -p ./", 112 | "watch": "tsc -watch -p ./", 113 | "pretest": "npm run compile && npm run lint", 114 | "lint": "eslint src --ext ts" 115 | }, 116 | "devDependencies": { 117 | "@types/glob": "^7.1.3", 118 | "@types/mocha": "^8.0.4", 119 | "@types/node": "^12.11.7", 120 | "@types/vscode": "^1.53.0", 121 | "@typescript-eslint/eslint-plugin": "^4.9.0", 122 | "@typescript-eslint/parser": "^4.9.0", 123 | "eslint": "^7.15.0", 124 | "glob": "^7.1.6", 125 | "mocha": "^8.1.3", 126 | "ts-loader": "^8.0.17", 127 | "typescript": "^4.1.2", 128 | "vscode-test": "^1.4.1", 129 | "webpack": "^5.23.0", 130 | "webpack-cli": "^4.5.0" 131 | }, 132 | "dependencies": { 133 | "@devicefarmer/adbkit": "^2.11.3", 134 | "concat-stream": "^2.0.0", 135 | "vscode-nls": "^5.0.0" 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/adb.ts: -------------------------------------------------------------------------------- 1 | import { Stats } from 'fs'; 2 | import { Readable } from 'stream'; 3 | import * as StreamUtils from './utils/stream'; 4 | const Adb = require('@devicefarmer/adbkit'); 5 | const concatStream = require('concat-stream'); 6 | 7 | const client = Adb.createClient(); 8 | 9 | function timeout(ms: number): Promise { 10 | return new Promise(async (resolve) => setTimeout(resolve, ms)); 11 | } 12 | 13 | export interface AdbDevice { 14 | id: string; 15 | type: AdbDeviceType; 16 | name?: string; 17 | } 18 | 19 | export interface AdbEntry { 20 | name: string; 21 | mode: number; 22 | size: number; 23 | mtime: Date; 24 | 25 | isFile(): boolean; 26 | } 27 | 28 | export type AdbDeviceType = 'device' | 'unauthorized' | 'offline' | 'fastboot' | 'recovery'; 29 | 30 | export async function listDevices(): Promise { 31 | const devices: AdbDevice[] = await client.listDevices(); 32 | for (const d of devices) { 33 | if (d.type !== 'unauthorized' && d.type !== 'offline') { 34 | d.name = await getProp(d.id, 'ro.product.device'); 35 | } 36 | } 37 | return devices; 38 | } 39 | 40 | export function getProp(device: string, key: string): Promise { 41 | return client.shell(device, `getprop ${key}`) 42 | .then(Adb.util.readAll) 43 | .then((out: Buffer) => out.toString().trim()); 44 | } 45 | 46 | export function stat(device: string, path: string): Promise { 47 | return client.stat(device, path); 48 | } 49 | 50 | export function readDirectory(device: string, path: string): Promise { 51 | return client.readdir(device, path); 52 | } 53 | 54 | export function readFile(device: string, path: string): Promise { 55 | return new Promise(async (resolve, reject) => { 56 | try { 57 | const transfer = await client.pull(device, path); 58 | const writable = concatStream({ encoding: 'uint8array' }, resolve); 59 | transfer.on('error', reject); 60 | transfer.pipe(writable); 61 | } catch (err) { 62 | reject(err); 63 | } 64 | }); 65 | } 66 | 67 | export async function writeFile(device: string, path: string, stream: Readable, overwrite: boolean = false): Promise { 68 | let pathExists = false; 69 | try { 70 | await client.stat(device, path); 71 | pathExists = true; 72 | } catch (ignored) { 73 | // Ignored 74 | } 75 | if (!overwrite && pathExists) { 76 | throw new Error('file exists'); 77 | } 78 | return await client.push(device, stream, path); 79 | } 80 | 81 | export function createDirectory(device: string, path: string): Promise { 82 | return client.shell(device, `mkdir "${path}"`) 83 | .then(() => timeout(200)); 84 | } 85 | 86 | export async function deleteDirectory(device: string, path: string): Promise { 87 | const out = await client.shell(device, `rmdir "${path}"`).then(Adb.util.read); 88 | const msg = out.toString().trim(); 89 | console.log(`shell output: ${msg}`); 90 | if (msg.endsWith(': Directory not empty')) { 91 | throw new Error('non-empty directory can\'t be deleted.'); 92 | } 93 | await timeout(200); 94 | return; 95 | } 96 | 97 | export async function deleteFile(device: string, path: string): Promise { 98 | await client.shell(device, `rm "${path}"`); 99 | await timeout(200); 100 | return; 101 | } 102 | 103 | export async function renameInSameDevice(device: string, oldPath: string, newPath: string, options?: { overwrite?: boolean }): Promise { 104 | let newPathExists = false; 105 | try { 106 | await client.stat(device, newPath); 107 | newPathExists = true; 108 | } catch (ignored) { 109 | // Ignored 110 | } 111 | if (options?.overwrite !== true && newPathExists) { 112 | throw new Error('target path already exists'); 113 | } 114 | await client.shell(device, `mv "${oldPath}" "${newPath}"`); 115 | await timeout(200); 116 | return; 117 | } 118 | 119 | export async function renameAcrossDevices(oldDevice: string, newDevice: string, oldPath: string, newPath: string, options?: { overwrite?: boolean }): Promise { 120 | let newPathExists = false; 121 | let isFile = false; 122 | try { 123 | const stat = await client.stat(newDevice, newPath); 124 | isFile = stat.isFile(); 125 | newPathExists = true; 126 | } catch (ignored) { 127 | // Ignored 128 | } 129 | if (options?.overwrite !== true && newPathExists) { 130 | throw new Error('target path already exists'); 131 | } 132 | if (!isFile) { 133 | throw new Error('only files are supported to move across devices'); 134 | } 135 | const oldData = await readFile(oldDevice, oldPath); 136 | await writeFile(newDevice, newPath, StreamUtils.toReadable(oldData), options?.overwrite === true); 137 | await deleteFile(oldDevice, oldPath); 138 | } 139 | -------------------------------------------------------------------------------- /src/fsprovider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Stats } from 'fs'; 3 | import * as adb from './adb'; 4 | import * as StreamUtils from './utils/stream'; 5 | 6 | export const ADB_URI_SCHEME = 'adbfile'; 7 | 8 | export class AdbUri { 9 | public deviceId: string; 10 | public rootType: string; 11 | public path: string; 12 | 13 | constructor(deviceId: string, rootType: string, path: string) { 14 | this.deviceId = deviceId; 15 | this.rootType = rootType; 16 | if (path.startsWith('/')) { 17 | this.path = path.substr(1); 18 | } else { 19 | this.path = path; 20 | } 21 | } 22 | 23 | get fullPath(): string { 24 | if (this.rootType === 'internal') { 25 | if (this.path === '') { 26 | return '/sdcard'; 27 | } else { 28 | return `/sdcard/${this.path}`; 29 | } 30 | } else { 31 | return `/${this.path}`; 32 | } 33 | } 34 | 35 | static fromVSCodeUri(uri: vscode.Uri): AdbUri { 36 | if (uri.scheme !== ADB_URI_SCHEME) { 37 | throw new Error(`the scheme of adb uri should be '${ADB_URI_SCHEME}'`); 38 | } 39 | const parts = uri.path.split('/'); 40 | if (parts[0] === '') { 41 | parts.shift(); 42 | } 43 | const [deviceId, rootType, ...path] = parts; 44 | return new AdbUri(deviceId, rootType, path.join('/')); 45 | } 46 | } 47 | 48 | export class AdbFileStat implements vscode.FileStat { 49 | type: vscode.FileType; 50 | ctime: number; 51 | mtime: number; 52 | size: number; 53 | name: string; 54 | 55 | constructor(opts: AdbFileStatOptions) { 56 | this.type = opts.type; 57 | this.ctime = opts.ctime ?? Date.now(); 58 | this.mtime = opts.mtime ?? Date.now(); 59 | this.size = opts.size ?? 0; 60 | this.name = opts.name; 61 | } 62 | 63 | static fromFsStats(stats: Stats): AdbFileStat { 64 | return new AdbFileStat({ 65 | type: stats.isFile() ? vscode.FileType.File : vscode.FileType.Directory, 66 | ctime: stats.ctime?.getTime(), 67 | mtime: stats.mtime?.getTime(), 68 | size: stats.isFile() ? stats.size : 0, 69 | name: '', 70 | }); 71 | } 72 | } 73 | 74 | interface AdbFileStatOptions { 75 | type: vscode.FileType; 76 | ctime?: number; 77 | mtime?: number; 78 | size?: number; 79 | name: string; 80 | } 81 | 82 | export class AdbFileSystemProvider implements vscode.FileSystemProvider { 83 | private onDidChangeFileEmitter = new vscode.EventEmitter(); 84 | readonly onDidChangeFile: vscode.Event = this.onDidChangeFileEmitter.event; 85 | 86 | public isDebugging: boolean = false; 87 | 88 | watch(uri: vscode.Uri, options: { recursive: boolean; excludes: string[]; }): vscode.Disposable { 89 | // TODO unimplemented 90 | return new vscode.Disposable(() => {}); 91 | } 92 | 93 | debugLog(message?: any, ...optionalParams: any[]) { 94 | if (this.isDebugging) { 95 | console.log(message, ...optionalParams); 96 | } 97 | } 98 | 99 | async stat(uri: vscode.Uri): Promise { 100 | const adbUri = AdbUri.fromVSCodeUri(uri); 101 | this.debugLog('stat:', adbUri); 102 | try { 103 | const stats = await adb.stat(adbUri.deviceId, adbUri.fullPath); 104 | return AdbFileStat.fromFsStats(stats); 105 | } catch (err) { 106 | if (err.code === 'ENOENT') { 107 | throw vscode.FileSystemError.FileNotFound(uri); 108 | } 109 | throw err; 110 | } 111 | } 112 | 113 | async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { 114 | const adbUri = AdbUri.fromVSCodeUri(uri); 115 | this.debugLog('readDirectory:', adbUri); 116 | const entries = await adb.readDirectory(adbUri.deviceId, adbUri.fullPath); 117 | return entries.map((entry: adb.AdbEntry) => 118 | [entry.name, entry.isFile() ? vscode.FileType.File : vscode.FileType.Directory]); 119 | } 120 | 121 | async createDirectory(uri: vscode.Uri): Promise { 122 | const adbUri = AdbUri.fromVSCodeUri(uri); 123 | this.debugLog('createDirectory:', adbUri); 124 | return await adb.createDirectory(adbUri.deviceId, adbUri.fullPath); 125 | } 126 | 127 | async readFile(uri: vscode.Uri): Promise { 128 | const adbUri = AdbUri.fromVSCodeUri(uri); 129 | this.debugLog('readFile:', adbUri); 130 | return await adb.readFile(adbUri.deviceId, adbUri.fullPath); 131 | } 132 | 133 | async writeFile(uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean; }): Promise { 134 | const adbUri = AdbUri.fromVSCodeUri(uri); 135 | this.debugLog('writeFile:', adbUri); 136 | return await adb.writeFile(adbUri.deviceId, adbUri.fullPath, StreamUtils.toReadable(content), options.overwrite); 137 | } 138 | 139 | async delete(uri: vscode.Uri, options: { recursive: boolean; }): Promise { 140 | const adbUri = AdbUri.fromVSCodeUri(uri); 141 | this.debugLog('delete:', adbUri); 142 | const stats = await this.stat(uri); 143 | if (stats.type === vscode.FileType.Directory) { 144 | return await adb.deleteDirectory(adbUri.deviceId, adbUri.fullPath); 145 | } 146 | return await adb.deleteFile(adbUri.deviceId, adbUri.fullPath); 147 | } 148 | 149 | async rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean; }): Promise { 150 | const oldAdbUri = AdbUri.fromVSCodeUri(oldUri); 151 | const newAdbUri = AdbUri.fromVSCodeUri(newUri); 152 | this.debugLog('rename: %s => %s', oldAdbUri, newAdbUri); 153 | if (oldAdbUri.deviceId !== newAdbUri.deviceId) { 154 | return await adb.renameAcrossDevices(oldAdbUri.deviceId, newAdbUri.deviceId, 155 | oldAdbUri.fullPath, newAdbUri.fullPath, options); 156 | } 157 | return await adb.renameInSameDevice(oldAdbUri.deviceId, oldAdbUri.fullPath, newAdbUri.fullPath, options); 158 | } 159 | } 160 | --------------------------------------------------------------------------------