├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── .yarnrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── flatpak.png ├── package.json ├── src ├── buildPipeline.ts ├── command.ts ├── extension.ts ├── flatpak.types.ts ├── flatpakUtils.ts ├── integration │ ├── base.ts │ ├── index.ts │ ├── mesonBuild.ts │ ├── rustAnalyzer.ts │ └── vala.ts ├── lazy.ts ├── manifest.ts ├── manifestManager.ts ├── manifestMap.ts ├── manifestUtils.ts ├── migration.ts ├── nodePty.ts ├── outputTerminal.ts ├── runner.ts ├── runnerStatusItem.ts ├── taskMode.ts ├── test │ ├── assets │ │ ├── .has.invalid.AppId.json │ │ ├── has.missing.AppId.json │ │ ├── has.missing.Modules.json │ │ ├── org.gnome.Screenshot.json │ │ ├── org.valid.Manifest.json │ │ ├── org.valid.Manifest.jsonc │ │ ├── org.valid.Manifest.yaml │ │ └── org.valid.Manifest.yml │ ├── runTest.ts │ └── suite │ │ ├── extension.test.ts │ │ ├── index.ts │ │ └── outputTerminal.test.ts ├── utils.ts └── workspaceState.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.ts] 12 | indent_size = 4 13 | 14 | [*.json] 15 | insert_final_newline = ignore 16 | 17 | [**.min.js] 18 | indent_style = ignore 19 | insert_final_newline = ignore 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "tsconfigRootDir": ".", 6 | "project": [ 7 | "./tsconfig.json" 8 | ] 9 | }, 10 | "env": { 11 | "node": true 12 | }, 13 | "extends": [ 14 | "eslint:recommended", 15 | "plugin:@typescript-eslint/recommended", 16 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 17 | ], 18 | "plugins": [ 19 | "@typescript-eslint" 20 | ], 21 | "rules": { 22 | "@typescript-eslint/naming-convention": "warn", 23 | "@typescript-eslint/semi": [ 24 | "warn", 25 | "never" 26 | ], 27 | "@typescript-eslint/quotes": [ 28 | "warn", 29 | "single" 30 | ], 31 | "@typescript-eslint/no-unnecessary-condition": "warn", 32 | "curly": "warn", 33 | "eqeqeq": "warn", 34 | "no-throw-literal": "warn", 35 | "no-path-concat": "warn" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [master] 4 | pull_request: 5 | workflow_dispatch: 6 | release: 7 | types: [published] 8 | 9 | name: CI 10 | jobs: 11 | codespell: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: codespell-project/actions-codespell@master 16 | with: 17 | check_filenames: true 18 | skip: "yarn.lock,.git" 19 | 20 | build: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | node-version: [16.x, 18.x] 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Tests with Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - run: yarn install 32 | - run: yarn run pretest 33 | - run: xvfb-run yarn run test 34 | 35 | publish: 36 | runs-on: ubuntu-latest 37 | if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'release' }} 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: actions/setup-node@v3 41 | with: 42 | node-version: 18.x 43 | - name: Publish 44 | run: | 45 | yarn install --dev 46 | yarn run deploy-vs -p ${{ secrets.VSCE_PAT }} 47 | yarn run deploy-ovsx -p ${{ secrets.OPEN_VSX_PAT }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | package-lock.json 6 | .flatpak 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true 6 | } 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": ["dbaeumer.vscode-eslint"] 5 | } 6 | -------------------------------------------------------------------------------- /.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": ["--extensionDevelopmentPath=${workspaceFolder}"], 13 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 14 | "preLaunchTask": "${defaultBuildTask}" 15 | }, 16 | { 17 | "name": "Extension Tests", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "args": [ 21 | "--extensionDevelopmentPath=${workspaceFolder}", 22 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 23 | ], 24 | "outFiles": ["${workspaceFolder}/out/test/**/*.js"], 25 | "preLaunchTask": "${defaultBuildTask}" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.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 | // Format the source files on save 10 | "[typescript]": { 11 | "editor.formatOnSave": true, 12 | "editor.defaultFormatter": "vscode.typescript-language-features" 13 | }, 14 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 15 | "typescript.tsc.autoDetect": "off", 16 | "cSpell.words": [ 17 | "Pseudoterminal", 18 | "Sandboxed", 19 | "appdir", 20 | "buildsystem", 21 | "ccache", 22 | "devel", 23 | "freedesktop", 24 | "nofilesystem" 25 | ], 26 | "files.watcherExclude": { 27 | "**/.git/objects/**": true, 28 | "**/.git/subtree-cache/**": true, 29 | "**/node_modules/*/**": true, 30 | "**/.hg/store/**": true, 31 | ".flatpak/**": true, 32 | "_build/**": true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | src/** 5 | .gitignore 6 | .yarnrc 7 | vsc-extension-quickstart.md 8 | **/tsconfig.json 9 | **/.eslintrc.json 10 | **/*.map 11 | **/*.ts 12 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | --ignore-engines true -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.0.38] 4 | 5 | - manifest: Allow to configure directories to ignore while searching for manifests 6 | 7 | ## [0.0.37] 8 | 9 | - manifest: Automatically set `development` flatpak manifests, suffixed with `Devel` / `.devel` by default 10 | - rust-analyzer: Ignore `.flatpak-builder`, `_build`, `build` directories 11 | - manifest: Support `FLATPAK_ID` / `FLATPAK_ARCH` / `FLATPAK_DEST` / `FLATPAK_BUILDER_N_JOBS` / `FLATPAK_BUILDER_BUILDDIR` variables 12 | 13 | ## [0.0.36] 14 | 15 | - misc: also exit spawned command as flatpak-spawn exit 16 | - Remove host filesystem build restriction 17 | 18 | ## [0.0.35] 19 | 20 | - Use relative path in generated settings.json 21 | - Fix logic when generating environment variables overrides 22 | - Fix simple build system generated commands 23 | 24 | ## [0.0.34] 25 | 26 | - Fix check of the fonts cache before running the application 27 | - Cache the a11y bus arguments 28 | 29 | ## [0.0.33] 30 | 31 | - Drop unneeded host permission 32 | - Fix: `TypeError` in podman development 33 | 34 | ## [0.0.32] 35 | 36 | - Mount fonts directories 37 | - Support running inside a container like toolbox 38 | - Expose session accessibility bus 39 | - Fix remote development support 40 | 41 | ## [0.0.31] 42 | 43 | - Stop wrapping `--talk-name=` ending with `*` in `''` 44 | 45 | ## [0.0.30] 46 | 47 | - Add command to show application's data directory 48 | - Fallback to the Flatpak-installed `flatpak-builder` (`org.flatpak.Builder`) when it is not found on host 49 | - Automatically resize output terminal when terminal window resizes 50 | - Drop rust-analyzer runnables.extraArgs target-dir override 51 | - Update to node v16 52 | - Don't require finish-args 53 | 54 | ## [0.0.29] 55 | 56 | - Update Flatpak logo 57 | 58 | ## [0.0.28] 59 | 60 | - Update Rust Analyzer extension ID 61 | 62 | ## [0.0.27] 63 | 64 | - Update Vala/Rust Analyzer integrations 65 | - Catch runner errors and avoid VSCode showing a message dialog for them 66 | - Other cleanup 67 | 68 | ## [0.0.26] 69 | 70 | - Only `appendWatcherExclude` when there is an active manifest 71 | - Don't show run/stop button when there is no active manifest 72 | - Disable keyboard shortcuts when there is no active manifest 73 | - Fix missing SDKs verification 74 | - Simplify the build pipeline 75 | - Fix multiple config-opts 76 | 77 | ## [0.0.25] 78 | 79 | - Added `mesonbuild.mesonbuild` extension integration. 80 | - Added `post-install` manifest option support. 81 | - Rename `rebuild` command to `build-and-run`. It would also now do a build automatically without having to run a separate `build` command. 82 | - Don't require for the build to be initialized when running `clean` command. 83 | - Add Export bundle command 84 | - Drop the `preview` flag 85 | 86 | ## [0.0.24] 87 | 88 | - New play and stop button in editor title UI 89 | - Build terminal now uses the application ID 90 | - Runtime terminal now uses the runtime ID 91 | - Show error if certain runtimes are not installed 92 | - Improved other extension integration API 93 | - Fix migration 94 | 95 | ## [0.0.23] 96 | 97 | - Integrate with Vala language server 98 | 99 | ## [0.0.22] 100 | 101 | - Support `x-run-args` 102 | - Fix state migration to the new format 103 | - Lazily load the Flatpak version (only needed when checking if --require-version is used) 104 | 105 | ## [0.0.21] 106 | 107 | - New Flatpak manifest selector 108 | - Watch for Flatpak manifests changes and modify state accordingly 109 | - Support JSON manifests with comments 110 | - Better state management 111 | 112 | ## [0.0.20] 113 | 114 | - Actually fix Runtime terminal when sandboxed 115 | 116 | ## [0.0.19] 117 | 118 | - Fix Build/Runtime terminal when sandboxed 119 | 120 | ## [0.0.18] 121 | 122 | - Fix colored outputs in sandboxed VSCode 123 | - Prevent terminal output from getting cut off in first launch 124 | 125 | ## [0.0.17] 126 | 127 | - New output terminal for less output delay and working terminal colors 128 | - New status bar item for current build and run status 129 | - New rust-analyzer integration to run runnables within the sandbox 130 | - Improved build and runtime terminal integration 131 | - Trigger documents portal in activate (May still be problematic when other extensions, like-rust-analyzer, startups earlier) 132 | - Display the "Flatpak manifest detected" dialog only once 133 | - Code cleanup 134 | 135 | ## [0.0.16] 136 | 137 | - Mark dependencies as not build after an update 138 | - Simplify the usage of the extensions, you can now just run a `Flatpak: build` from the command and it will do everything you need. You can followup with a `Flatpak: run` or a `Flatpak: rebuild the application` 139 | 140 | ## [0.0.15] 141 | 142 | - Don't hardcode the path to `/usr/bin/bash` 143 | - Code cleanup 144 | 145 | ## [0.0.14] 146 | 147 | - Make use of `prepend-path` `append-ld-library-path` `prepend-ld-library-path` `append-pkg-config-path` `prepend-pkg-config-path` in `build-options` when opening a build terminal 148 | 149 | ## [0.0.13] 150 | 151 | - support `prepend-path` `append-ld-library-path` `prepend-ld-library-path` `append-pkg-config-path` `prepend-pkg-config-path` in `build-options` 152 | - support `build-options` in a module 153 | - configure `rust-analyzer`'s `excludeDirs` option to exclude `.flatpak` 154 | - reset `rust-analyzer` overrides when the extension is disabled 155 | - use flatpak-builder schema from upstream repository 156 | 157 | ## [0.0.12] 158 | 159 | - use an output channel instead of a hackish terminal 160 | - autotools buildsystem support 161 | 162 | ## [0.0.11] 163 | 164 | - Allow to re-run a command if the latest one failed 165 | - Configure Rust-Analyzer only if the Flatpak repository is initialized 166 | 167 | ## [0.0.10] 168 | 169 | - Properly detect if a command is running before spawning the next one 170 | 171 | ## [0.0.9] 172 | 173 | - Forward host environment variables when running a command inside the sandbox 174 | - `cmake` & `cmake-ninja` build systems support 175 | - Use one terminal for all the extensions commands instead of spawning a new terminal per task 176 | - Save the pipeline state per project & restore it at start 177 | 178 | ## [0.0.8] 179 | 180 | - Properly initialize a new Rust project 181 | - Ensure `.flatpak` directory exists before re-configuring r-a 182 | 183 | ## [0.0.6] 184 | 185 | - New Command: Open Runtime Terminal 186 | - New Command: Open Build Terminal, like the runtime one but inside the current repository 187 | - Support Sandboxed Code: Automatically switch to flatpak-spawn --host $command if Code is running in a sandboxed environment like Flatpak 188 | - Rust Analyzer integration: Spawn R-A inside the sandbox if in a Rust project 189 | - Use a custom terminal provider: we can finally track if a command has failed to not trigger the next one automatically. The downside of this is we have lost colored outputs in the terminal for now... 190 | - Schema Fixes 191 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Installation 4 | 5 | ### Flatpaked VSCodium/VSCode 6 | 7 | 1. Install the node18 SDK extension by executing the following: 8 | ```bash 9 | flatpak install flathub org.freedesktop.Sdk.Extension.node18 10 | ``` 11 | Note: It will suggest multiple versions. To be sure which one to use, check the manifest in the flathub repo of [VSCodium](https://github.com/flathub/com.vscodium.codium/blob/master/com.vscodium.codium.yaml)/[VSCode](https://github.com/flathub/com.visualstudio.code/blob/master/com.visualstudio.code.yaml). 12 | 13 | 2. Enable it by adding the following line to `~/.bash_profile`: 14 | ```bash 15 | export FLATPAK_ENABLE_SDK_EXT=node18 16 | ``` 17 | 18 | 3. Log out and in again. 19 | 20 | 4. Open the `flatpak-vscode` repository with your editor. 21 | 22 | 5. Within the integrated terminal of your editor, execute the following commands at the root of the repository: 23 | ```bash 24 | yarn install 25 | ``` 26 | 27 | 6. To start debugging, run `F5`. 28 | 29 | ### Directly installed VSCodium/VSCode 30 | 31 | 1. Install `yarn` with your preferred method and make sure it is in your `PATH`. 32 | 33 | 2. Execute the following at the root of the repository: 34 | ```bash 35 | yarn install 36 | ``` 37 | 38 | 3. To start debugging, run `F5`. 39 | 40 | 41 | ## Integration with other extensions 42 | 43 | To add an integration, follow the following steps: 44 | 45 | 1. Create a new file in `src/integration/`. 46 | 2. Create a new class in the created file that extends the `Integration` abstract class from `src/integration/base.ts`. It has the following abstract methods: 47 | - `isApplicable`: The integration will only be loaded on context where this returns true. Extend `SdkIntegration` instead to have this default to whether if current manifest has the required SDK extension. 48 | - `load`: This is called when loading your integration. 49 | - `unload`: This is called when unloading your integration. This is where you should put the cleanups. 50 | 3. The constructor needs the following parameters: 51 | - `extensionId`: The VSCode ID of the extension you are integrating. 52 | - `sdkExtension`: For which SDK Extension should it be enabled. If it doesn't exist, update `Manifest.sdkExtensions` method in `src/manifest.ts`. This is only needed when extending `SdkIntegration`. 53 | 4. Don't forget to append an instance of your class to `INTEGRATIONS` in `src/integration/index.ts`. 54 | 5. You can also add documentations for your integration in `README.md`. 55 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bilal Elmoussaoui 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VSCode + Flatpak Integration 2 | 3 | ![CI](https://github.com/bilelmoussaoui/flatpak-vscode/workflows/CI/badge.svg) ![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/bilelmoussaoui.flatpak-vscode) 4 | [![Matrix Chat](https://img.shields.io/badge/Matrix-Chat-green)](https://matrix.to/#/#flatpak-vscode:gnome.org) 5 | 6 | A simple VSCode extension that detects a Flatpak manifest and offers various commands to build, run, and export a bundle. 7 | 8 | ## Download 9 | 10 | - [Microsoft Visual Studio Code Marketplace](https://marketplace.visualstudio.com/items?itemName=bilelmoussaoui.flatpak-vscode) 11 | - [Open VSX Registry](https://open-vsx.org/extension/bilelmoussaoui/flatpak-vscode) 12 | 13 | ## Requirements 14 | 15 | - `flatpak` 16 | - `flatpak-builder` 17 | 18 | If you're using Fedora Silverblue, it is recommended to install `org.flatpak.Builder` through `flatpak install org.flatpak.Builder`. However, layering `flatpak-builder` through `rpm-ostree install flatpak-builder` is still possible. The extension would use the host `flatpak-builder` by default and fallback to the flatpak-installed `org.flatpak.Builder`. 19 | 20 | ## Commands 21 | 22 | - Build: Initialize a Flatpak build, update the dependencies & build them. It also does a first build of the application. 23 | - Build and Run: Build or rebuild the application then run it. 24 | - Stop: Stop the currently running task. 25 | - Run: Run the application. 26 | - Update Dependencies: Download/Update the dependencies and builds them. 27 | - Clean: Clean the Flatpak repo directory (`.flatpak/repo`) inside the current workspace. 28 | - Runtime Terminal: Spawn a new terminal inside the specified SDK. 29 | - Build Terminal: Spawn a new terminal inside the current build repository (Note that the SDKs used are automatically mounted and enabled as well). 30 | - Show Output Terminal: Show the output terminal of the build and run commands. 31 | - Show Data Directory: Show the data directory (`~/.var/app/`) for the active manifest. 32 | - Select Manifest: Select or change the active manifest. 33 | 34 | ## Integrations 35 | 36 | Other extensions like `rust-analyzer` and `vala` mostly works better if it integrates with the 37 | Flatpak runtime. Some integrations may prevent rebuilds or requiring to install dependencies in 38 | the host. If you want to contribute on adding an integration, see [CONTRIBUTING](CONTRIBUTING.md). 39 | 40 | ### [Meson Build](https://marketplace.visualstudio.com/items?itemName=mesonbuild.mesonbuild) 41 | 42 | - Overrides `mesonbuild.configureOnOpen` to not ask to configure the build directory; this should be handled by Flatpak. 43 | - Overrides `mesonbuild.buildFolder` to use the build directory used by Flatpak. 44 | - Overrides `mesonbuild.mesonPath` to use the meson binary from the SDK. 45 | 46 | ### [Rust Analyzer](https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer) 47 | 48 | - Overrides `rust-analyzer.server.path` and `rust-analyzer.runnables.command` to use the SDK's rust-analyzer and cargo binaries respectively. This is to avoid requiring build dependencies to be installed in the host. 49 | - Overrides `rust-analyzer.files.excludeDirs` to set rust-analyzer to ignore `.flatpak` folder. 50 | 51 | ### [Vala](https://marketplace.visualstudio.com/items?itemName=prince781.vala) 52 | 53 | - Overrides `vala.languageServerPath` to use the SDK's Vala Language Server. 54 | 55 | ## Contributing 56 | 57 | Click [here](CONTRIBUTING.md) to find out how to contribute. 58 | -------------------------------------------------------------------------------- /flatpak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bilelmoussaoui/flatpak-vscode/c42b55980a7555ac68ba656eae18dd74d7c60a05/flatpak.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flatpak-vscode", 3 | "displayName": "Flatpak", 4 | "description": "Provides Flatpak manifest integration for building & running your application", 5 | "keywords": [ 6 | "flatpak", 7 | "flatpak-builder" 8 | ], 9 | "publisher": "bilelmoussaoui", 10 | "author": { 11 | "name": "Bilal Elmoussaoui", 12 | "email": "bil.elmoussaoui@gmail.com" 13 | }, 14 | "contributors": [ 15 | { 16 | "name": "Julian Hofer", 17 | "email": "julianhofer@gnome.org" 18 | }, 19 | { 20 | "name": "Dave Patrick" 21 | } 22 | ], 23 | "license": "MIT", 24 | "extensionKind": [ 25 | "workspace" 26 | ], 27 | "os": [ 28 | "linux" 29 | ], 30 | "markdown": "github", 31 | "icon": "flatpak.png", 32 | "repository": { 33 | "url": "https://github.com/bilelmoussaoui/flatpak-vscode.git", 34 | "type": "git" 35 | }, 36 | "bugs": { 37 | "url": "https://github.com/bilelmoussaoui/flatpak-vscode/issues" 38 | }, 39 | "version": "0.0.38", 40 | "engines": { 41 | "vscode": "^1.50.0" 42 | }, 43 | "categories": [ 44 | "Other" 45 | ], 46 | "activationEvents": [ 47 | "workspaceContains:**/*.{json,yaml,yml}" 48 | ], 49 | "main": "./out/extension.js", 50 | "contributes": { 51 | "commands": [ 52 | { 53 | "command": "flatpak-vscode.show-data-directory", 54 | "title": "Show Data Directory", 55 | "category": "Flatpak" 56 | }, 57 | { 58 | "command": "flatpak-vscode.select-manifest", 59 | "title": "Select or Change Active Manifest", 60 | "category": "Flatpak" 61 | }, 62 | { 63 | "command": "flatpak-vscode.show-output-terminal", 64 | "title": "Show Output Terminal", 65 | "category": "Flatpak" 66 | }, 67 | { 68 | "command": "flatpak-vscode.runtime-terminal", 69 | "title": "Open a Runtime Terminal", 70 | "category": "Flatpak" 71 | }, 72 | { 73 | "command": "flatpak-vscode.build-terminal", 74 | "title": "Open a Build Terminal", 75 | "category": "Flatpak" 76 | }, 77 | { 78 | "command": "flatpak-vscode.update-deps", 79 | "title": "Update Dependencies", 80 | "category": "Flatpak" 81 | }, 82 | { 83 | "command": "flatpak-vscode.run", 84 | "title": "Run", 85 | "category": "Flatpak" 86 | }, 87 | { 88 | "command": "flatpak-vscode.build-and-run", 89 | "title": "Build and Run", 90 | "icon": "$(play)", 91 | "category": "Flatpak" 92 | }, 93 | { 94 | "command": "flatpak-vscode.export", 95 | "title": "Export Bundle", 96 | "category": "Flatpak" 97 | }, 98 | { 99 | "command": "flatpak-vscode.stop", 100 | "title": "Stop Current Command", 101 | "icon": "$(debug-stop)", 102 | "category": "Flatpak" 103 | }, 104 | { 105 | "command": "flatpak-vscode.clean", 106 | "title": "Clean Up Build Directories", 107 | "category": "Flatpak" 108 | }, 109 | { 110 | "command": "flatpak-vscode.build", 111 | "title": "Build", 112 | "category": "Flatpak" 113 | } 114 | ], 115 | "menus": { 116 | "commandPalette": [ 117 | { 118 | "command": "flatpak-vscode.runtime-terminal", 119 | "when": "flatpakHasActiveManifest" 120 | }, 121 | { 122 | "command": "flatpak-vscode.build-terminal", 123 | "when": "flatpakHasActiveManifest" 124 | }, 125 | { 126 | "command": "flatpak-vscode.update-deps", 127 | "when": "flatpakHasActiveManifest" 128 | }, 129 | { 130 | "command": "flatpak-vscode.run", 131 | "when": "flatpakHasActiveManifest && flatpakApplicationBuilt && !flatpakRunnerActive" 132 | }, 133 | { 134 | "command": "flatpak-vscode.build-and-run", 135 | "when": "flatpakHasActiveManifest && flatpakApplicationBuilt && !flatpakRunnerActive" 136 | }, 137 | { 138 | "command": "flatpak-vscode.stop", 139 | "when": "flatpakHasActiveManifest && flatpakRunnerActive" 140 | }, 141 | { 142 | "command": "flatpak-vscode.clean", 143 | "when": "flatpakHasActiveManifest" 144 | }, 145 | { 146 | "command": "flatpak-vscode.export", 147 | "when": "flatpakHasActiveManifest && flatpakApplicationBuilt" 148 | }, 149 | { 150 | "command": "flatpak-vscode.build", 151 | "when": "flatpakHasActiveManifest && !flatpakApplicationBuilt" 152 | } 153 | ], 154 | "editor/title/run": [ 155 | { 156 | "command": "flatpak-vscode.build-and-run", 157 | "when": "flatpakHasActiveManifest && !flatpakRunnerActive" 158 | }, 159 | { 160 | "command": "flatpak-vscode.stop", 161 | "when": "flatpakHasActiveManifest && flatpakRunnerActive" 162 | } 163 | ] 164 | }, 165 | "keybindings": [ 166 | { 167 | "command": "flatpak-vscode.build-and-run", 168 | "linux": "ctrl+alt+B", 169 | "when": "flatpakHasActiveManifest && !flatpakRunnerActive" 170 | }, 171 | { 172 | "command": "flatpak-vscode.stop", 173 | "linux": "ctrl+alt+B", 174 | "when": "flatpakHasActiveManifest && flatpakRunnerActive" 175 | }, 176 | { 177 | "command": "flatpak-vscode.run", 178 | "linux": "ctrl+alt+R", 179 | "when": "flatpakHasActiveManifest && !flatpakRunnerActive" 180 | } 181 | ], 182 | "terminal": { 183 | "profiles": [ 184 | { 185 | "id": "flatpak-vscode.runtime-terminal-provider", 186 | "title": "Flatpak Runtime Terminal" 187 | }, 188 | { 189 | "id": "flatpak-vscode.build-terminal-provider", 190 | "title": "Flatpak Build Terminal" 191 | } 192 | ] 193 | }, 194 | "jsonValidation": [ 195 | { 196 | "fileMatch": [ 197 | "*.*.*.json", 198 | "*.*.*.*.json", 199 | "*.*.*.*.*.json", 200 | "!/settings.json" 201 | ], 202 | "url": "https://raw.githubusercontent.com/flatpak/flatpak-builder/master/data/flatpak-manifest.schema.json" 203 | } 204 | ], 205 | "yamlValidation": [ 206 | { 207 | "fileMatch": [ 208 | "*.*.*.yaml", 209 | "*.*.*.*.yaml", 210 | "*.*.*.*.*.yaml", 211 | "*.*.*.yml", 212 | "*.*.*.*.yml", 213 | "*.*.*.*.*.yml" 214 | ], 215 | "url": "https://raw.githubusercontent.com/flatpak/flatpak-builder/master/data/flatpak-manifest.schema.json" 216 | } 217 | ], 218 | "configuration": { 219 | "title": "Flatpak", 220 | "properties": { 221 | "flatpak-vscode.excludeManifestDirs": { 222 | "markdownDescription": "These directories, with `.flatpak` and `_build`, will be ignored when searching for Flatpak manifests. You may also need to add these folders to Code's `files.watcherExclude` for performance.", 223 | "default": [ 224 | "target", 225 | ".vscode", 226 | ".flatpak-builder", 227 | "flatpak_app", 228 | ".github" 229 | ], 230 | "type": "array", 231 | "items": { 232 | "type": "string" 233 | } 234 | } 235 | } 236 | } 237 | }, 238 | "scripts": { 239 | "vscode:prepublish": "yarn run compile", 240 | "compile": "tsc -p ./", 241 | "lint": "eslint src --ext ts", 242 | "watch": "tsc -watch -p ./", 243 | "pretest": "yarn run compile && copyfiles -u 1 src/test/assets/* out/ && yarn run lint --max-warnings=0", 244 | "test": "node ./out/test/runTest.js", 245 | "deploy-vs": "vsce publish --yarn", 246 | "deploy-ovsx": "ovsx publish --yarn" 247 | }, 248 | "devDependencies": { 249 | "@types/glob": "^8.1.0", 250 | "@types/js-yaml": "^4.0.9", 251 | "@types/mocha": "^10.0.6", 252 | "@types/node": "^20.11.5", 253 | "@types/vscode": "^1.50.0", 254 | "@typescript-eslint/eslint-plugin": "^6.19.0", 255 | "@typescript-eslint/parser": "^6.19.0", 256 | "copyfiles": "^2.4.1", 257 | "eslint": "^8.56.0", 258 | "glob": "^10.3.10", 259 | "mocha": "^10.2.0", 260 | "node-pty": "^1.0.0", 261 | "ovsx": "^0.8.3", 262 | "typescript": "^5.3.3", 263 | "vsce": "^2.6.7", 264 | "vscode-test": "^1.4.0" 265 | }, 266 | "dependencies": { 267 | "dbus-next": "^0.10.2", 268 | "js-yaml": "^4.1.0", 269 | "jsonc-parser": "^3.0.0" 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/buildPipeline.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceState } from './workspaceState' 2 | import { Runner } from './runner' 3 | import { TaskMode } from './taskMode' 4 | import { OutputTerminal } from './outputTerminal' 5 | import { loadIntegrations } from './integration' 6 | import * as vscode from 'vscode' 7 | import * as path from 'path' 8 | import * as fs from 'fs/promises' 9 | import { Manifest } from './manifest' 10 | 11 | export class BuildPipeline implements vscode.Disposable { 12 | private readonly workspaceState: WorkspaceState 13 | private readonly runner: Runner 14 | private readonly outputTerminal: OutputTerminal 15 | 16 | constructor(workspaceState: WorkspaceState) { 17 | this.workspaceState = workspaceState 18 | 19 | this.outputTerminal = new OutputTerminal() 20 | this.runner = new Runner(this.outputTerminal) 21 | } 22 | 23 | async showOutputTerminal(preserveFocus?: boolean) { 24 | await this.outputTerminal.show(preserveFocus) 25 | } 26 | 27 | /** 28 | * Ensure that the build environment is initialized 29 | */ 30 | async ensureInitializedBuild(manifest: Manifest) { 31 | if (await manifest.isBuildInitialized()) { 32 | console.log('Skipped build initialization. Already initialized.') 33 | return 34 | } 35 | 36 | this.runner.ensureIdle() 37 | await this.runner.execute([manifest.initBuild()], TaskMode.buildInit) 38 | await loadIntegrations(manifest) 39 | } 40 | 41 | /** 42 | * Update the application's dependencies 43 | */ 44 | async updateDependencies(manifest: Manifest) { 45 | this.runner.ensureIdle() 46 | await this.ensureInitializedBuild(manifest) 47 | 48 | await this.runner.execute([await manifest.updateDependencies()], TaskMode.updateDeps) 49 | 50 | await this.workspaceState.setDependenciesUpdated(true) 51 | // Assume user might want to rebuild dependencies 52 | await this.workspaceState.setDependenciesBuilt(false) 53 | } 54 | 55 | /** 56 | * Build the application's dependencies 57 | */ 58 | async buildDependencies(manifest: Manifest) { 59 | this.runner.ensureIdle() 60 | 61 | if (this.workspaceState.getDependenciesBuilt()) { 62 | console.log('Skipped buildDependencies. Dependencies are already built.') 63 | return 64 | } 65 | 66 | await this.runner.execute([await manifest.buildDependencies()], TaskMode.buildDeps) 67 | 68 | await this.workspaceState.setDependenciesBuilt(true) 69 | } 70 | 71 | async buildApplication(manifest: Manifest) { 72 | this.runner.ensureIdle() 73 | 74 | if (!this.workspaceState.getDependenciesBuilt()) { 75 | console.log('Cannot build application; dependencies are not built.') 76 | return 77 | } 78 | 79 | await this.runner.execute(manifest.build(false), TaskMode.buildApp) 80 | 81 | await this.workspaceState.setApplicationBuilt(true) 82 | } 83 | 84 | async rebuildApplication(manifest: Manifest) { 85 | this.runner.ensureIdle() 86 | 87 | if (!this.workspaceState.getApplicationBuilt()) { 88 | console.log('Skipped rebuild. The application was not built.') 89 | return 90 | } 91 | 92 | await this.runner.execute(manifest.build(true), TaskMode.rebuild) 93 | 94 | await this.workspaceState.setApplicationBuilt(true) 95 | } 96 | 97 | /** 98 | * A helper method to chain up commands based on current pipeline state 99 | */ 100 | async build(manifest: Manifest) { 101 | this.runner.ensureIdle() 102 | await this.ensureInitializedBuild(manifest) 103 | 104 | if (!this.workspaceState.getDependenciesUpdated()) { 105 | await this.updateDependencies(manifest) 106 | } 107 | 108 | await this.buildDependencies(manifest) 109 | 110 | if (this.workspaceState.getApplicationBuilt()) { 111 | await this.rebuildApplication(manifest) 112 | } else { 113 | await this.buildApplication(manifest) 114 | } 115 | } 116 | 117 | /** 118 | * Run the application, only if it was already built 119 | */ 120 | async run(manifest: Manifest) { 121 | this.runner.ensureIdle() 122 | 123 | if (!this.workspaceState.getApplicationBuilt()) { 124 | console.log('Skipped run; the application is not built.') 125 | return 126 | } 127 | 128 | await this.runner.execute([await manifest.run()], TaskMode.run) 129 | this.outputTerminal.appendMessage('Application exited') 130 | } 131 | 132 | /** 133 | * Export a Flatpak bundle, only if the application was already built 134 | */ 135 | async exportBundle(manifest: Manifest) { 136 | this.runner.ensureIdle() 137 | 138 | if (!this.workspaceState.getApplicationBuilt()) { 139 | console.log('Skipped exportBundle. Application is not built.') 140 | return 141 | } 142 | 143 | await this.runner.execute(await manifest.bundle(), TaskMode.export) 144 | 145 | void vscode.window.showInformationMessage('Flatpak bundle has been exported successfully.', 'Show bundle') 146 | .then((response) => { 147 | if (response !== 'Show bundle') { 148 | return 149 | } 150 | 151 | void vscode.env.openExternal(vscode.Uri.file(manifest.workspace)) 152 | }) 153 | } 154 | 155 | /** 156 | * Clean build environment 157 | */ 158 | async clean(manifest: Manifest) { 159 | this.runner.ensureIdle() 160 | 161 | await this.outputTerminal.show(true) 162 | 163 | this.outputTerminal.appendMessage('Clean up build directories') 164 | 165 | this.outputTerminal.append('Deleting Flatpak repository directory...') 166 | await manifest.deleteRepoDir() 167 | this.outputTerminal.appendLine('done') 168 | 169 | const buildSystemDir = manifest.buildSystemBuildDir() 170 | if (buildSystemDir) { 171 | this.outputTerminal.append(`Deleting ${buildSystemDir} directory...`) 172 | await fs.rm(path.join(manifest.workspace, buildSystemDir), { 173 | recursive: true, 174 | force: true, 175 | }) 176 | this.outputTerminal.appendLine('done') 177 | } 178 | 179 | this.outputTerminal.append('Resetting pipeline state...') 180 | await this.resetState() 181 | this.outputTerminal.appendLine('done') 182 | 183 | this.outputTerminal.appendLine('All done!') 184 | } 185 | 186 | /** 187 | * Clear and stop the running commands in the runner 188 | */ 189 | async stop() { 190 | await this.runner.stop() 191 | } 192 | 193 | async resetState() { 194 | await this.workspaceState.setDependenciesUpdated(false) 195 | await this.workspaceState.setDependenciesBuilt(false) 196 | await this.workspaceState.setApplicationBuilt(false) 197 | } 198 | 199 | async dispose() { 200 | this.outputTerminal.dispose() 201 | await this.runner.dispose() 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises' 2 | import * as pty from './nodePty' 3 | import { PathLike } from 'fs' 4 | import { execFileSync, execFile, ChildProcess } from 'child_process' 5 | import { OutputTerminal } from './outputTerminal' 6 | import { CancellationToken } from 'vscode' 7 | import { AsyncLazy, Lazy } from './lazy' 8 | import { IS_SANDBOXED } from './extension' 9 | import { exists } from './utils' 10 | 11 | /** 12 | * Whether flatpak-builder is installed on the host 13 | */ 14 | const FLATPAK_BUILDER_HOST_EXISTS = new Lazy(() => { 15 | try { 16 | const version = new Command('flatpak-builder', ['--version']) 17 | .execSync() 18 | .toString() 19 | .replace('flatpak-builder', '') 20 | .trim() 21 | console.log(`host flatpak-builder version: ${version}`) 22 | return true 23 | } catch (error) { 24 | console.log(`host flatpak-builder not found: ${error as string}`) 25 | return false 26 | } 27 | }) 28 | 29 | /** 30 | * Whether flatpak-builder is installed as a Flatpak (org.flatpak.Builder) 31 | */ 32 | const FLATPAK_BUILDER_SANDBOXED_EXISTS = new Lazy(() => { 33 | try { 34 | const version = new Command('flatpak', ['run', 'org.flatpak.Builder', '--version']) 35 | .execSync() 36 | .toString() 37 | .replace('flatpak-builder', '') 38 | .trim() 39 | console.log(`flatpak-installed flatpak-builder version: ${version}`) 40 | return true 41 | } catch (error) { 42 | console.log(`flatpak-installed flatpak-builder not found: ${error as string}`) 43 | return false 44 | } 45 | }) 46 | 47 | /** 48 | * Whether host-spawn is installed. 49 | */ 50 | const HOST_SPAWN_EXISTS = new Lazy(() => { 51 | try { 52 | const version = execFileSync('host-spawn', ['--version']).toString().trim() 53 | console.log(`host-spawn version: ${version}`) 54 | return true 55 | } catch (error) { 56 | console.log(`host-spawn not found: ${error as string}`) 57 | return false 58 | } 59 | }) 60 | 61 | /** 62 | * Whether VSCode is running inside a container like Toolbx or distrobox 63 | */ 64 | const VSCODE_INSIDE_CONTAINER = new AsyncLazy(async () => { 65 | try { 66 | const containerEnv = '/run/.containerenv' 67 | if (await exists(containerEnv)) { 68 | console.log('VSCode is running inside a container') 69 | return true 70 | } else { 71 | return false 72 | } 73 | } catch (error) { 74 | console.log('Failed to check if running inside a container') 75 | return false 76 | } 77 | }) 78 | 79 | export class Canceled extends Error { 80 | constructor() { 81 | super('Cancelled task') 82 | } 83 | } 84 | 85 | export interface CommandOptions { 86 | cwd?: string 87 | /** 88 | * Should only be used when running tests or debugging. 89 | */ 90 | forceSandbox?: boolean 91 | } 92 | 93 | /** 94 | * Tries to run the command in the host environment 95 | */ 96 | export class Command { 97 | readonly program: string 98 | readonly args: string[] 99 | private readonly cwd?: string 100 | 101 | constructor(program: string, args: string[], options?: CommandOptions) { 102 | if (options?.forceSandbox || IS_SANDBOXED.get()) { 103 | if (HOST_SPAWN_EXISTS.get()) { 104 | this.program = 'host-spawn' 105 | args.unshift(program) 106 | } else { 107 | this.program = 'flatpak-spawn' 108 | args.unshift('--host', '--watch-bus', '--env=TERM=xterm-256color', program) 109 | } 110 | } else { 111 | this.program = program 112 | } 113 | this.args = args 114 | this.cwd = options?.cwd 115 | } 116 | 117 | static async flatpakBuilder(args: string[], options?: CommandOptions): Promise { 118 | // flatpak-builder requires --disable-rofiles-fuse when running inside a container 119 | if (await VSCODE_INSIDE_CONTAINER.get()) { 120 | args = [...args, '--disable-rofiles-fuse'] 121 | } 122 | 123 | if (FLATPAK_BUILDER_HOST_EXISTS.get()) { 124 | return new Command('flatpak-builder', args, options) 125 | } else if (FLATPAK_BUILDER_SANDBOXED_EXISTS.get()) { 126 | return new Command('flatpak', ['run', 'org.flatpak.Builder', ...args], options) 127 | } else { 128 | // User may have installed either after receiving the error 129 | // so invalidate to check again if either now exists 130 | FLATPAK_BUILDER_HOST_EXISTS.reset() 131 | FLATPAK_BUILDER_SANDBOXED_EXISTS.reset() 132 | 133 | throw new Error('Flatpak builder was not found. Please install either `flatpak-builder` from your distro repositories or `org.flatpak.Builder` through `flatpak install`') 134 | } 135 | } 136 | 137 | toString(): string { 138 | return [this.program, ...this.args].join(' ') 139 | } 140 | 141 | /** 142 | * Store the command as a bash script 143 | * @param path save location 144 | */ 145 | async saveAsScript(path: PathLike): Promise { 146 | const args = this.args.filter((arg) => arg !== '--env=TERM=xterm-256color') 147 | if (this.program === 'host-spawn') { 148 | args.unshift('-no-pty') 149 | } 150 | const commandStr = [this.program, ...args].join(' ') 151 | 152 | const fileContents = ['#!/bin/sh', '', `${commandStr} "$@"`].join('\n') 153 | await fs.writeFile(path, fileContents) 154 | await fs.chmod(path, 0o755) 155 | } 156 | 157 | execSync(): Buffer { 158 | return execFileSync(this.program, this.args, { 159 | cwd: this.cwd 160 | }) 161 | } 162 | 163 | exec(): ChildProcess { 164 | return execFile(this.program, this.args, { cwd: this.cwd }) 165 | } 166 | 167 | /** 168 | * Spawn this with using node-pty 169 | * @param terminal Where the output stream will be sent 170 | * @param token For cancellation. This will send SIGINT on the process when cancelled. 171 | * @returns the process 172 | */ 173 | spawn(terminal: OutputTerminal, token: CancellationToken): Promise { 174 | const iPty = pty.spawn(this.program, this.args, { 175 | cwd: this.cwd, 176 | cols: terminal.dimensions?.columns, 177 | rows: terminal.dimensions?.rows, 178 | }) 179 | 180 | const onDidSetDimensionsHandler = terminal.onDidSetDimensions((dimensions) => { 181 | iPty.resize(dimensions.columns, dimensions.rows) 182 | }) 183 | 184 | iPty.onData((data) => { 185 | terminal.append(data) 186 | }) 187 | 188 | return new Promise((resolve, reject) => { 189 | token.onCancellationRequested(() => { 190 | iPty.kill('SIGINT') 191 | }) 192 | 193 | iPty.onExit(({ exitCode, signal }) => { 194 | onDidSetDimensionsHandler.dispose() 195 | 196 | if (exitCode !== 0) { 197 | reject(new Error(`Child process exited with code ${exitCode}`)) 198 | return 199 | } 200 | 201 | if (signal === 2) { // SIGINT 202 | reject(new Canceled()) 203 | return 204 | } 205 | 206 | resolve(iPty) 207 | }) 208 | }) 209 | } 210 | } 211 | 212 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { window, ExtensionContext, commands } from 'vscode' 3 | import { existsSync } from 'fs' 4 | import { ensureDocumentsPortal, appendWatcherExclude, showDataDirectory } from './utils' 5 | import { TaskMode } from './taskMode' 6 | import { ManifestManager } from './manifestManager' 7 | import { Manifest } from './manifest' 8 | import { WorkspaceState } from './workspaceState' 9 | import { migrateStateToMemento } from './migration' 10 | import { BuildPipeline } from './buildPipeline' 11 | import { loadIntegrations, unloadIntegrations } from './integration' 12 | import { RunnerError } from './runner' 13 | import { Lazy } from './lazy' 14 | 15 | export const EXTENSION_ID = 'flatpak-vscode' 16 | 17 | /** 18 | * Whether VSCode is installed in a sandbox 19 | */ 20 | export const IS_SANDBOXED = new Lazy(() => { 21 | const isSandboxed = existsSync('/.flatpak-info') 22 | console.log(`is VSCode running in sandbox: ${isSandboxed.toString()}`) 23 | return isSandboxed 24 | }) 25 | 26 | class Extension { 27 | private readonly extCtx: vscode.ExtensionContext 28 | private readonly workspaceState: WorkspaceState 29 | private readonly manifestManager: ManifestManager 30 | private readonly buildPipeline: BuildPipeline 31 | 32 | constructor(extCtx: vscode.ExtensionContext) { 33 | this.extCtx = extCtx 34 | this.workspaceState = new WorkspaceState(extCtx) 35 | 36 | this.manifestManager = new ManifestManager(this.workspaceState) 37 | this.extCtx.subscriptions.push(this.manifestManager) 38 | this.manifestManager.onDidActiveManifestChanged(async ([manifest, isLastActive]) => { 39 | await this.handleActiveManifestChanged(manifest, isLastActive) 40 | }) 41 | 42 | this.buildPipeline = new BuildPipeline(this.workspaceState) 43 | this.extCtx.subscriptions.push(this.buildPipeline) 44 | 45 | this.manifestManager.onDidRequestRebuild(async (manifest) => { 46 | if (this.manifestManager.isActiveManifest(manifest)) { 47 | console.log(`Manifest at ${manifest.uri.fsPath} requested a rebuild`) 48 | await manifest.deleteRepoDir() 49 | await this.buildPipeline.resetState() 50 | } 51 | }) 52 | } 53 | 54 | async activate() { 55 | await migrateStateToMemento(this.workspaceState) 56 | await this.workspaceState.loadContexts() 57 | 58 | // Private commands 59 | this.registerCommand('show-active-manifest', async () => { 60 | const activeManifest = await this.manifestManager.getActiveManifestUnchecked() 61 | await vscode.window.showTextDocument(activeManifest.uri) 62 | }) 63 | 64 | // Public commands 65 | this.registerCommand('show-data-directory', async () => { 66 | const activeManifest = await this.manifestManager.getActiveManifest() 67 | showDataDirectory(activeManifest.id()) 68 | }) 69 | 70 | this.registerCommand('select-manifest', async () => { 71 | await this.manifestManager.selectManifest() 72 | }) 73 | 74 | this.registerCommand('runtime-terminal', async () => { 75 | const activeManifest = await this.manifestManager.getActiveManifest() 76 | const runtimeTerminal = window.createTerminal(activeManifest.runtimeTerminal()) 77 | this.extCtx.subscriptions.push(runtimeTerminal) 78 | runtimeTerminal.show() 79 | }) 80 | 81 | this.registerCommand('build-terminal', async () => { 82 | const activeManifest = await this.manifestManager.getActiveManifest() 83 | const buildTerminal = window.createTerminal(await activeManifest.buildTerminal()) 84 | this.extCtx.subscriptions.push(buildTerminal) 85 | buildTerminal.show() 86 | }) 87 | 88 | this.registerCommand('show-output-terminal', async () => { 89 | await this.buildPipeline.showOutputTerminal() 90 | }) 91 | 92 | this.registerCommand(TaskMode.updateDeps, async () => { 93 | const activeManifest = await this.manifestManager.getActiveManifest() 94 | await this.buildPipeline.updateDependencies(activeManifest) 95 | await this.buildPipeline.buildDependencies(activeManifest) 96 | }) 97 | 98 | this.registerCommand('build-and-run', async () => { 99 | const activeManifest = await this.manifestManager.getActiveManifest() 100 | await this.buildPipeline.build(activeManifest) 101 | await this.buildPipeline.run(activeManifest) 102 | }) 103 | 104 | this.registerCommand(TaskMode.stop, async () => { 105 | await this.buildPipeline.stop() 106 | }) 107 | 108 | this.registerCommand(TaskMode.clean, async () => { 109 | const activeManifest = await this.manifestManager.getActiveManifest() 110 | await this.buildPipeline.clean(activeManifest) 111 | }) 112 | 113 | this.registerCommand(TaskMode.run, async () => { 114 | const activeManifest = await this.manifestManager.getActiveManifest() 115 | await this.buildPipeline.run(activeManifest) 116 | }) 117 | 118 | this.registerCommand(TaskMode.export, async () => { 119 | const activeManifest = await this.manifestManager.getActiveManifest() 120 | await this.buildPipeline.exportBundle(activeManifest) 121 | }) 122 | 123 | this.registerCommand('build', async () => { 124 | const activeManifest = await this.manifestManager.getActiveManifest() 125 | await this.buildPipeline.build(activeManifest) 126 | }) 127 | 128 | this.registerTerminalProfileProvider('runtime-terminal-provider', { 129 | provideTerminalProfile: async () => { 130 | const activeManifest = await this.manifestManager.getActiveManifest() 131 | return new vscode.TerminalProfile(activeManifest.runtimeTerminal()) 132 | } 133 | }) 134 | 135 | this.registerTerminalProfileProvider('build-terminal-provider', { 136 | provideTerminalProfile: async () => { 137 | const activeManifest = await this.manifestManager.getActiveManifest() 138 | await this.buildPipeline.ensureInitializedBuild(activeManifest) 139 | return new vscode.TerminalProfile(await activeManifest.buildTerminal()) 140 | } 141 | }) 142 | 143 | console.log('All commands and terminal profile providers are now registered.') 144 | 145 | await this.manifestManager.loadLastActiveManifest() 146 | } 147 | 148 | async deactivate() { 149 | const activeManifest = await this.manifestManager.getActiveManifest() 150 | await unloadIntegrations(activeManifest) 151 | } 152 | 153 | private registerTerminalProfileProvider(name: string, provider: vscode.TerminalProfileProvider) { 154 | this.extCtx.subscriptions.push(window.registerTerminalProfileProvider(`${EXTENSION_ID}.${name}`, provider)) 155 | } 156 | 157 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 158 | private registerCommand(name: string, callback: (...args: any) => Promise) { 159 | this.extCtx.subscriptions.push( 160 | commands.registerCommand(`${EXTENSION_ID}.${name}`, async (args) => { 161 | try { 162 | await callback(args) 163 | } catch (err) { 164 | if (err instanceof RunnerError) { 165 | return 166 | } 167 | 168 | throw err 169 | } 170 | }) 171 | ) 172 | } 173 | 174 | private async handleActiveManifestChanged(manifest: Manifest | null, isLastActive: boolean) { 175 | if (manifest === null) { 176 | return 177 | } 178 | 179 | if (!isLastActive) { 180 | await this.buildPipeline.stop() 181 | await this.buildPipeline.resetState() 182 | 183 | await manifest.deleteRepoDir() 184 | await unloadIntegrations(manifest) 185 | } 186 | 187 | await appendWatcherExclude(['.flatpak/**', '_build/**']) 188 | 189 | await this.buildPipeline.ensureInitializedBuild(manifest) 190 | await loadIntegrations(manifest) 191 | } 192 | } 193 | 194 | let extension: Extension | undefined 195 | 196 | export async function activate(extCtx: ExtensionContext): Promise { 197 | void ensureDocumentsPortal() 198 | 199 | extension = new Extension(extCtx) 200 | await extension.activate() 201 | } 202 | 203 | export async function deactivate(): Promise { 204 | await extension?.deactivate() 205 | extension = undefined 206 | } 207 | -------------------------------------------------------------------------------- /src/flatpak.types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | 3 | import { PathLike } from 'fs' 4 | 5 | export interface ManifestSchema { 6 | id?: string 7 | branch?: string 8 | 'app-id'?: string 9 | modules: Module[] 10 | sdk: string 11 | runtime: string 12 | 'runtime-version': string 13 | 'sdk-extensions'?: string[] 14 | command: string 15 | 'finish-args'?: string[] 16 | 'build-options'?: BuildOptions 17 | 'x-run-args'?: string[] 18 | } 19 | 20 | export type BuildOptionsPathKeys = 'append-path' | 'prepend-path' | 21 | 'append-ld-library-path' | 22 | 'prepend-ld-library-path' | 23 | 'append-pkg-config-path' | 24 | 'prepend-pkg-config-path' 25 | 26 | export interface BuildOptions { 27 | 'build-args': string[] 28 | 'append-path'?: PathLike 29 | 'prepend-path'?: PathLike 30 | 'append-ld-library-path'?: PathLike 31 | 'prepend-ld-library-path'?: PathLike 32 | 'append-pkg-config-path'?: PathLike 33 | 'prepend-pkg-config-path'?: PathLike 34 | env: Record 35 | 'config-opts': string[] 36 | } 37 | 38 | export type BuildSystem = 'meson' | 'cmake' | 'cmake-ninja' | 39 | 'simple' | 'autotools' | 'qmake' 40 | 41 | export interface Module { 42 | name: string 43 | buildsystem?: BuildSystem 44 | 'config-opts'?: string[] 45 | sources: Source[] 46 | 'build-commands': string[] 47 | 'build-options'?: BuildOptions 48 | 'post-install'?: string[] 49 | } 50 | 51 | export type SourceType = 'archive' | 'git' | 52 | 'bzr' | 'svn' | 'dir' | 'file' | 53 | 'script' | 'inline' | 'shell' | 54 | 'patch' | 'extra-data' 55 | 56 | export interface Source { 57 | type: SourceType 58 | url?: URL 59 | path?: PathLike 60 | tag?: string 61 | commit?: string 62 | sha256?: string 63 | } 64 | 65 | export type SdkExtension = 'vala' 66 | | 'rust-stable' 67 | | 'rust-nightly' 68 | -------------------------------------------------------------------------------- /src/flatpakUtils.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './command' 2 | 3 | export interface FlatpakEntry { 4 | id: string 5 | version: string 6 | } 7 | 8 | /** 9 | * Retrieves the list of installed flatpak applications or runtimes. 10 | * @param type applications or runtimes 11 | */ 12 | export function getAvailable(type: 'app' | 'runtime'): FlatpakEntry[] { 13 | const command = new Command('flatpak', ['list', `--${type}`, '--columns=application,branch']) 14 | const result = command.execSync().toString() 15 | 16 | const runtimes = [] 17 | for (const line of result.split(/\r?\n/)) { // Split at new line 18 | const [id, version] = line.split(/\s+/) // Split at whitespace 19 | 20 | if (id && version) { 21 | runtimes.push({ id, version }) 22 | } 23 | } 24 | 25 | return runtimes 26 | } 27 | 28 | /** 29 | * Check if version1 is newer or equal than version2 30 | * @param version1 a flatpak version, usually returned by flatpak --version 31 | * @param version2 a flatpak version, required by the manifest 32 | * @returns Whether version1 is newer or equal than version2 33 | */ 34 | export function versionCompare(version1: string, version2: string): boolean { 35 | // Ideally, this should maybe be a more sophisticated check 36 | return version1 >= version2 37 | } 38 | -------------------------------------------------------------------------------- /src/integration/base.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { SdkExtension } from '../flatpak.types' 3 | import { Manifest } from '../manifest' 4 | 5 | /** 6 | * Derive from this when needed more control for `isApplicable`. 7 | */ 8 | export abstract class Integration { 9 | readonly extensionId: string 10 | 11 | constructor(extensionId: string) { 12 | this.extensionId = extensionId 13 | } 14 | 15 | /** 16 | * Whether the extension this is integrating is enabled. 17 | */ 18 | isExtensionEnabled(): boolean { 19 | return vscode.extensions.getExtension(this.extensionId) !== undefined 20 | } 21 | 22 | /** 23 | * Whether the integration is applicable to current context. It will only 24 | * be loaded on scenario where this returns true. 25 | * @param manifest contains necessary context 26 | */ 27 | abstract isApplicable(manifest: Manifest): boolean 28 | 29 | /** 30 | * Called when loading the integration. 31 | * @param manifest contains the necessary context 32 | */ 33 | abstract load(manifest: Manifest): Promise 34 | 35 | /** 36 | * Called when unloading the integration. This mostly includes the cleanups. 37 | * @param manifest contains the necessary context 38 | */ 39 | abstract unload(manifest: Manifest): Promise 40 | } 41 | 42 | /** 43 | * Derive from this when creating an integration that requires a specific SDK extension. 44 | */ 45 | export abstract class SdkIntegration extends Integration { 46 | private readonly associatedSdkExtensions: SdkExtension[] 47 | 48 | constructor(extensionId: string, associatedSdkExtensions: SdkExtension[]) { 49 | super(extensionId) 50 | this.associatedSdkExtensions = associatedSdkExtensions 51 | } 52 | 53 | isApplicable(manifest: Manifest): boolean { 54 | for (const sdkExtension of this.associatedSdkExtensions) { 55 | if (manifest.sdkExtensions().includes(sdkExtension)) { 56 | return true 57 | } 58 | } 59 | 60 | return false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/integration/index.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from '../manifest' 2 | import { MesonBuild } from './mesonBuild' 3 | import { RustAnalyzer } from './rustAnalyzer' 4 | import { Vala } from './vala' 5 | import { window } from 'vscode' 6 | 7 | const INTEGRATIONS = [ 8 | new MesonBuild(), 9 | new RustAnalyzer(), 10 | new Vala(), 11 | ] 12 | 13 | export async function loadIntegrations(manifest: Manifest) { 14 | for (const integration of INTEGRATIONS) { 15 | console.log(`Trying to load integration ${integration.extensionId}`) 16 | if (integration.isApplicable(manifest) && integration.isExtensionEnabled()) { 17 | try { 18 | await integration.load(manifest) 19 | console.log(`Loaded integration ${integration.constructor.name}`) 20 | } catch (err) { 21 | void window.showErrorMessage(`Failed to load ${integration.constructor.name} integration: ${err as string}`) 22 | } 23 | } else { 24 | console.log(`Integration ${integration.extensionId} is not applicable`) 25 | } 26 | } 27 | } 28 | 29 | export async function unloadIntegrations(manifest: Manifest) { 30 | for (const integration of INTEGRATIONS) { 31 | console.log(`Trying to unload integration ${integration.extensionId}`) 32 | if (integration.isApplicable(manifest) && integration.isExtensionEnabled()) { 33 | try { 34 | await integration.unload(manifest) 35 | console.log(`Unloaded integration ${integration.constructor.name}`) 36 | } catch (err) { 37 | void window.showWarningMessage(`Failed to unload ${integration.constructor.name} integration: ${err as string}`) 38 | } 39 | } else { 40 | console.log(`Integration ${integration.extensionId} is not applicable`) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/integration/mesonBuild.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from '../manifest' 2 | import { Integration } from './base' 3 | 4 | export class MesonBuild extends Integration { 5 | constructor() { 6 | super('mesonbuild.mesonbuild') 7 | } 8 | 9 | isApplicable(manifest: Manifest): boolean { 10 | return manifest.module().buildsystem === 'meson' 11 | } 12 | 13 | async load(manifest: Manifest): Promise { 14 | await manifest.overrideWorkspaceConfig('mesonbuild', 'configureOnOpen', false) 15 | 16 | const buildSystemBuildDir = manifest.buildSystemBuildDir() 17 | if (buildSystemBuildDir !== null) { 18 | await manifest.overrideWorkspaceConfig('mesonbuild', 'buildFolder', buildSystemBuildDir) 19 | } 20 | 21 | await manifest.overrideWorkspaceCommandConfig('mesonbuild', 'mesonPath', 'meson', '/usr/bin/') 22 | } 23 | 24 | async unload(manifest: Manifest): Promise { 25 | await manifest.restoreWorkspaceConfig('mesonbuild', 'configureOnOpen') 26 | await manifest.restoreWorkspaceConfig('mesonbuild', 'buildFolder') 27 | await manifest.restoreWorkspaceConfig('mesonbuild', 'mesonPath') 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/integration/rustAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from '../manifest' 2 | import { SdkIntegration } from './base' 3 | 4 | export class RustAnalyzer extends SdkIntegration { 5 | constructor() { 6 | super('rust-lang.rust-analyzer', ['rust-stable', 'rust-nightly']) 7 | } 8 | 9 | async load(manifest: Manifest): Promise { 10 | let sdkFolder 11 | if (manifest.sdkExtensions().includes('rust-nightly')) { 12 | sdkFolder = 'rust-nightly' 13 | } else if (manifest.sdkExtensions().includes('rust-stable')) { 14 | sdkFolder = 'rust-stable' 15 | } else { 16 | throw new Error('unreachable code') 17 | } 18 | const binPath = `/usr/lib/sdk/${sdkFolder}/bin/` 19 | 20 | await manifest.overrideWorkspaceCommandConfig('rust-analyzer', 'server.path', 'rust-analyzer', binPath) 21 | 22 | const buildSystemBuildDir = manifest.buildSystemBuildDir() 23 | if (buildSystemBuildDir !== null) { 24 | const envArgs = new Map([['CARGO_HOME', `${buildSystemBuildDir}/cargo-home`]]) 25 | await manifest.overrideWorkspaceCommandConfig('rust-analyzer', 'runnables.command', 'cargo', binPath, envArgs) 26 | } 27 | 28 | await manifest.overrideWorkspaceConfig('rust-analyzer', 'files.excludeDirs', ['.flatpak', '.flatpak-builder', '_build', 'build', 'builddir']) 29 | } 30 | 31 | async unload(manifest: Manifest): Promise { 32 | await manifest.restoreWorkspaceConfig('rust-analyzer', 'server.path') 33 | await manifest.restoreWorkspaceConfig('rust-analyzer', 'runnables.command') 34 | await manifest.restoreWorkspaceConfig('rust-analyzer', 'files.excludeDirs') 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/integration/vala.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from '../manifest' 2 | import { SdkIntegration } from './base' 3 | 4 | export class Vala extends SdkIntegration { 5 | constructor() { 6 | super('prince781.vala', ['vala']) 7 | } 8 | 9 | async load(manifest: Manifest): Promise { 10 | await manifest.overrideWorkspaceCommandConfig('vala', 'languageServerPath', 'vala-language-server', '/usr/lib/sdk/vala/bin/') 11 | } 12 | 13 | async unload(manifest: Manifest): Promise { 14 | await manifest.restoreWorkspaceConfig('vala', 'languageServerPath') 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/lazy.ts: -------------------------------------------------------------------------------- 1 | export class Lazy { 2 | private readonly factory: () => T 3 | private value: T | undefined 4 | 5 | constructor(factory: () => T) { 6 | this.factory = factory 7 | this.value = undefined 8 | } 9 | 10 | /** 11 | * Returns the cached value or initialize first. 12 | */ 13 | get(): T { 14 | if (this.value === undefined) { 15 | this.value = this.factory() 16 | } 17 | return this.value 18 | } 19 | 20 | /** 21 | * Resets the cached value to an undefined state. 22 | */ 23 | reset() { 24 | this.value = undefined 25 | } 26 | } 27 | 28 | export class AsyncLazy { 29 | private readonly factory: () => Promise 30 | private value: T | undefined 31 | 32 | constructor(factory: () => Promise) { 33 | this.factory = factory 34 | this.value = undefined 35 | } 36 | 37 | /** 38 | * Returns the cached value or initialize first. 39 | */ 40 | async get(): Promise { 41 | if (this.value === undefined) { 42 | this.value = await this.factory() 43 | } 44 | return this.value 45 | } 46 | 47 | /** 48 | * Resets the cached value to an undefined state. 49 | */ 50 | reset() { 51 | this.value = undefined 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { BuildOptionsPathKeys, ManifestSchema, Module, SdkExtension } from './flatpak.types' 3 | import * as path from 'path' 4 | import { arch, cpus } from 'os' 5 | import * as fs from 'fs/promises' 6 | import { Command } from './command' 7 | import { generatePathOverride, getA11yBusArgs, getFontsArgs, getHostEnv } from './utils' 8 | import { versionCompare } from './flatpakUtils' 9 | import { checkForMissingRuntimes } from './manifestUtils' 10 | import { Lazy } from './lazy' 11 | 12 | /** 13 | * Version of currently installed Flatpak in host 14 | */ 15 | export const FLATPAK_VERSION = new Lazy(() => { 16 | const version = new Command('flatpak', ['--version']) 17 | .execSync() 18 | .toString() 19 | .replace('Flatpak', '') 20 | .trim() 21 | console.log(`Flatpak version: '${version}'`) 22 | return version 23 | }) 24 | 25 | const DEFAULT_BUILD_SYSTEM_BUILD_DIR = '_build' 26 | 27 | export class Manifest { 28 | readonly uri: vscode.Uri 29 | readonly manifest: ManifestSchema 30 | private readonly repoDir: string 31 | private readonly finializedRepoDir: string 32 | private readonly ostreeRepoPath: string 33 | private fontsArgs: string[] 34 | private a11yBusArgs: string[] 35 | readonly buildDir: string 36 | readonly workspace: string 37 | readonly stateDir: string 38 | readonly requiredVersion?: string 39 | 40 | constructor( 41 | uri: vscode.Uri, 42 | manifest: ManifestSchema, 43 | ) { 44 | this.uri = uri 45 | this.manifest = manifest 46 | this.workspace = vscode.workspace.getWorkspaceFolder(uri)?.uri.fsPath || '' 47 | this.buildDir = path.join(this.workspace, '.flatpak') 48 | this.repoDir = path.join(this.buildDir, 'repo') 49 | this.finializedRepoDir = path.join(this.buildDir, 'finalized-repo') 50 | this.ostreeRepoPath = path.join(this.buildDir, 'ostree-repo') 51 | this.stateDir = path.join(this.buildDir, 'flatpak-builder') 52 | this.requiredVersion = (manifest['finish-args'] || []).map((val) => val.split('=')).find((value) => { 53 | return value[0] === '--require-version' 54 | })?.[1] 55 | this.fontsArgs = [] 56 | this.a11yBusArgs = [] 57 | } 58 | 59 | async isBuildInitialized(): Promise { 60 | const repoDir = vscode.Uri.file(this.repoDir) 61 | const metadataFile = vscode.Uri.joinPath(repoDir, 'metadata') 62 | const filesDir = vscode.Uri.joinPath(repoDir, 'files') 63 | const varDir = vscode.Uri.joinPath(repoDir, 'var') 64 | 65 | try { 66 | // From gnome-builder 67 | // https://gitlab.gnome.org/GNOME/gnome-builder/-/blob/8579055f5047a0af5462e8a587b0742014d71d64/src/plugins/flatpak/gbp-flatpak-pipeline-addin.c#L220 68 | return (await vscode.workspace.fs.stat(metadataFile)).type === vscode.FileType.File 69 | && (await vscode.workspace.fs.stat(filesDir)).type === vscode.FileType.Directory 70 | && (await vscode.workspace.fs.stat(varDir)).type === vscode.FileType.Directory 71 | } catch (err) { 72 | if (err instanceof vscode.FileSystemError && err.code === 'FileNotFound') { 73 | return false 74 | } 75 | 76 | throw err 77 | } 78 | } 79 | 80 | /** 81 | * Check for invalidity in the manifest 82 | * @returns an Error with a message if there is an error otherwise null 83 | */ 84 | checkForError(): Error | null { 85 | if (this.requiredVersion !== undefined) { 86 | const flatpakVersion = FLATPAK_VERSION.get() 87 | if (!versionCompare(flatpakVersion, this.requiredVersion)) { 88 | return new Error(`Manifest requires ${this.requiredVersion} but ${flatpakVersion} is available.`) 89 | } 90 | } 91 | 92 | const missingRuntimes = checkForMissingRuntimes(this) 93 | if (missingRuntimes.length !== 0) { 94 | return new Error(`Manifest requires the following but are not installed: ${missingRuntimes.join(', ')}`) 95 | } 96 | 97 | return null 98 | } 99 | 100 | id(): string { 101 | return this.manifest['app-id'] || this.manifest.id || 'org.flatpak.Test' 102 | } 103 | 104 | sdkExtensions(): SdkExtension[] { 105 | const rawSdkExtensions = this.manifest['sdk-extensions'] 106 | 107 | if (rawSdkExtensions === undefined) { 108 | return [] 109 | } 110 | 111 | const sdkExtensions: SdkExtension[] = [] 112 | for (const rawSdkExtension of rawSdkExtensions) { 113 | const suffix = rawSdkExtension.split('.').pop() 114 | 115 | if (suffix === undefined) { 116 | continue 117 | } 118 | 119 | switch (suffix) { 120 | case 'rust-stable': 121 | sdkExtensions.push('rust-stable') 122 | break 123 | case 'rust-nightly': 124 | sdkExtensions.push('rust-nightly') 125 | break 126 | case 'vala': 127 | sdkExtensions.push('vala') 128 | break 129 | default: 130 | console.warn(`SDK extension '${suffix}' was not handled`) 131 | } 132 | } 133 | 134 | return sdkExtensions 135 | } 136 | 137 | /** 138 | * Returns the the latest Flatpak module 139 | */ 140 | module(): Module { 141 | return this.manifest.modules.slice(-1)[0] 142 | } 143 | 144 | /** 145 | * Returns the manifest path 146 | */ 147 | path(): string { 148 | return this.uri.fsPath 149 | } 150 | 151 | finishArgs(): string[] { 152 | return (this.manifest['finish-args'] || []) 153 | .filter((arg) => { 154 | // --metadata causes a weird issue 155 | // --require-version is not supported by flatpak-builder, so filter it out 156 | return !['--metadata', '--require-version'].includes(arg.split('=')[0]) 157 | }) 158 | } 159 | 160 | runtimeTerminal(): vscode.TerminalOptions { 161 | const sdkId = `${this.manifest.sdk}//${this.manifest['runtime-version']}` 162 | const command = new Command('flatpak', [ 163 | 'run', 164 | '--command=bash', 165 | sdkId, 166 | ]) 167 | return { 168 | name: sdkId, 169 | iconPath: new vscode.ThemeIcon('package'), 170 | shellPath: command.program, 171 | shellArgs: command.args 172 | } 173 | } 174 | 175 | async buildTerminal(): Promise { 176 | const command = await this.runInRepo('bash', true) 177 | return { 178 | name: this.id(), 179 | iconPath: new vscode.ThemeIcon('package'), 180 | shellPath: command.program, 181 | shellArgs: command.args, 182 | } 183 | } 184 | 185 | initBuild(): Command { 186 | return new Command( 187 | 'flatpak', 188 | [ 189 | 'build-init', 190 | this.repoDir, 191 | this.id(), 192 | this.manifest.sdk, 193 | this.manifest.runtime, 194 | this.manifest['runtime-version'], 195 | ], 196 | { cwd: this.workspace }, 197 | ) 198 | } 199 | 200 | async updateDependencies(): Promise { 201 | const args = [ 202 | '--ccache', 203 | '--force-clean', 204 | '--disable-updates', 205 | '--download-only', 206 | ] 207 | args.push(`--state-dir=${this.stateDir}`) 208 | args.push(`--stop-at=${this.module().name}`) 209 | args.push(this.repoDir) 210 | args.push(this.path()) 211 | 212 | return await Command.flatpakBuilder( 213 | args, 214 | { cwd: this.workspace }, 215 | ) 216 | } 217 | 218 | async buildDependencies(): Promise { 219 | const args = [ 220 | '--ccache', 221 | '--force-clean', 222 | '--disable-updates', 223 | '--disable-download', 224 | '--build-only', 225 | '--keep-build-dirs', 226 | ] 227 | args.push(`--state-dir=${this.stateDir}`) 228 | args.push(`--stop-at=${this.module().name}`) 229 | args.push(this.repoDir) 230 | args.push(this.path()) 231 | 232 | return await Command.flatpakBuilder( 233 | args, 234 | { cwd: this.workspace }, 235 | ) 236 | } 237 | 238 | /** 239 | * Generate a new PATH like override 240 | * @param envVariable the env variable name 241 | * @param defaultValue the default value 242 | * @param prependOption an array of the paths to pre-append 243 | * @param appendOption an array of the paths to append 244 | * @returns the new path 245 | */ 246 | getPathOverrides(envVariable: string, defaultValue: string[], prependOption: BuildOptionsPathKeys, appendOption: BuildOptionsPathKeys): string { 247 | const module = this.module() 248 | const prependPaths = [ 249 | this.manifest['build-options']?.[prependOption], 250 | module['build-options']?.[prependOption] 251 | ] 252 | const appendPaths = [ 253 | this.manifest['build-options']?.[appendOption], 254 | module['build-options']?.[appendOption] 255 | ] 256 | const currentValue = process.env[envVariable] 257 | const path = generatePathOverride(currentValue, defaultValue, prependPaths, appendPaths) 258 | return `--env=${envVariable}=${path}` 259 | } 260 | 261 | getPaths(): string[] { 262 | const paths: string[] = [] 263 | paths.push( 264 | this.getPathOverrides('PATH', 265 | ['/app/bin', '/usr/bin'], 266 | 'prepend-path', 'append-path' 267 | ) 268 | ) 269 | paths.push( 270 | this.getPathOverrides('LD_LIBRARY_PATH', 271 | ['/app/lib'], 272 | 'prepend-ld-library-path', 273 | 'append-ld-library-path' 274 | ) 275 | ) 276 | paths.push( 277 | this.getPathOverrides('PKG_CONFIG_PATH', 278 | [ 279 | '/app/lib/pkgconfig', 280 | '/app/share/pkgconfig', 281 | '/usr/lib/pkgconfig', 282 | '/usr/share/pkgconfig' 283 | ], 284 | 'prepend-pkg-config-path', 285 | 'append-pkg-config-path' 286 | ) 287 | ) 288 | return paths 289 | } 290 | 291 | build(rebuild: boolean): Command[] { 292 | const module = this.module() 293 | const buildEnv = { 294 | ...this.manifest['build-options']?.env || {}, 295 | ...module['build-options']?.env || {}, 296 | } 297 | let buildArgs = [ 298 | '--share=network', 299 | `--filesystem=${this.workspace}`, 300 | `--filesystem=${this.repoDir}`, 301 | ] 302 | 303 | for (const [key, value] of Object.entries(buildEnv)) { 304 | buildArgs.push(`--env=${key}=${value}`) 305 | } 306 | buildArgs = buildArgs.concat(this.getPaths()) 307 | 308 | const configOpts = ( 309 | (module['config-opts'] || []).concat( 310 | this.manifest['build-options']?.['config-opts'] || [] 311 | ) 312 | ) 313 | 314 | let commands = [] 315 | switch (module.buildsystem) { 316 | default: 317 | case 'autotools': 318 | commands = this.getAutotoolsCommands(rebuild, buildArgs, configOpts) 319 | break 320 | case 'cmake': 321 | case 'cmake-ninja': 322 | commands = this.getCmakeCommands(rebuild, buildArgs, configOpts) 323 | break 324 | case 'meson': 325 | commands = this.getMesonCommands(rebuild, buildArgs, configOpts) 326 | break 327 | case 'simple': 328 | commands = this.getSimpleCommands(module.name, module['build-commands'], buildArgs) 329 | break 330 | case 'qmake': 331 | throw new Error('Qmake is not implemented yet') 332 | } 333 | /// Add the post-install commands if there are any 334 | commands.push( 335 | ... this.getSimpleCommands(this.module().name, this.module()['post-install'] || [], buildArgs) 336 | ) 337 | return commands 338 | } 339 | 340 | buildSystemBuildDir(): string | null { 341 | const module = this.module() 342 | switch (module.buildsystem) { 343 | case 'meson': 344 | case 'cmake': 345 | case 'cmake-ninja': 346 | return DEFAULT_BUILD_SYSTEM_BUILD_DIR 347 | } 348 | return null 349 | } 350 | 351 | /** 352 | * Gets an array of commands for a autotools build 353 | * - If the app is being rebuilt 354 | * - Configure with `configure` 355 | * - Build with `make` 356 | * - Install with `make install` 357 | * @param {string} rebuild Whether this is a rebuild 358 | * @param {string[]} buildArgs The build arguments 359 | * @param {string} configOpts The configuration options 360 | */ 361 | getAutotoolsCommands( 362 | rebuild: boolean, 363 | buildArgs: string[], 364 | configOpts: string[] 365 | ): Command[] { 366 | const numCPUs = cpus().length 367 | const commands: Command[] = [] 368 | if (!rebuild) { 369 | commands.push( 370 | new Command( 371 | 'flatpak', 372 | [ 373 | 'build', 374 | ...buildArgs, 375 | this.repoDir, 376 | './configure', 377 | '--prefix=/app', 378 | ...configOpts, 379 | ], 380 | { cwd: this.workspace }, 381 | ) 382 | ) 383 | } 384 | commands.push( 385 | new Command( 386 | 'flatpak', 387 | ['build', ...buildArgs, this.repoDir, 'make', '-p', '-n', '-s'], 388 | { cwd: this.workspace }, 389 | ) 390 | ) 391 | 392 | commands.push( 393 | new Command( 394 | 'flatpak', 395 | ['build', ...buildArgs, this.repoDir, 'make', 'V=0', `-j${numCPUs}`, 'install'], 396 | { cwd: this.workspace }, 397 | ) 398 | ) 399 | return commands 400 | } 401 | 402 | /** 403 | * Gets an array of commands for a cmake build 404 | * - If the app is being rebuilt 405 | * - Ensure build dir exists 406 | * - Configure with `cmake -G NINJA` 407 | * - Build with `ninja` 408 | * - Install with `ninja install` 409 | * @param {string} rebuild Whether this is a rebuild 410 | * @param {string[]} buildArgs The build arguments 411 | * @param {string} configOpts The configuration options 412 | */ 413 | getCmakeCommands( 414 | rebuild: boolean, 415 | buildArgs: string[], 416 | configOpts: string[] 417 | ): Command[] { 418 | const commands: Command[] = [] 419 | const cmakeBuildDir = DEFAULT_BUILD_SYSTEM_BUILD_DIR 420 | buildArgs.push(`--filesystem=${this.workspace}/${cmakeBuildDir}`) 421 | if (!rebuild) { 422 | commands.push( 423 | new Command( 424 | 'mkdir', 425 | ['-p', cmakeBuildDir], 426 | { cwd: this.workspace }, 427 | ) 428 | ) 429 | commands.push( 430 | new Command( 431 | 'flatpak', 432 | [ 433 | 'build', 434 | ...buildArgs, 435 | this.repoDir, 436 | 'cmake', 437 | '-G', 438 | 'Ninja', 439 | '..', 440 | '.', 441 | '-DCMAKE_EXPORT_COMPILE_COMMANDS=1', 442 | '-DCMAKE_BUILD_TYPE=RelWithDebInfo', 443 | '-DCMAKE_INSTALL_PREFIX=/app', 444 | ...configOpts, 445 | ], 446 | { cwd: path.join(this.workspace, cmakeBuildDir) }, 447 | ) 448 | ) 449 | } 450 | commands.push( 451 | new Command( 452 | 'flatpak', 453 | ['build', ...buildArgs, this.repoDir, 'ninja'], 454 | { cwd: path.join(this.workspace, cmakeBuildDir) }, 455 | ) 456 | ) 457 | 458 | commands.push( 459 | new Command( 460 | 'flatpak', 461 | ['build', ...buildArgs, this.repoDir, 'ninja', 'install'], 462 | { cwd: path.join(this.workspace, cmakeBuildDir) }, 463 | ) 464 | ) 465 | return commands 466 | } 467 | 468 | /** 469 | * Gets an array of commands for a meson build 470 | * - If the app is being rebuilt 471 | * - Configure with `meson` 472 | * - Build with `ninja` 473 | * - Install with `meson install` 474 | * @param {string} rebuild Whether this is a rebuild 475 | * @param {string[]} buildArgs The build arguments 476 | * @param {string} configOpts The configuration options 477 | */ 478 | getMesonCommands( 479 | rebuild: boolean, 480 | buildArgs: string[], 481 | configOpts: string[] 482 | ): Command[] { 483 | const commands: Command[] = [] 484 | const mesonBuildDir = DEFAULT_BUILD_SYSTEM_BUILD_DIR 485 | buildArgs.push(`--filesystem=${this.workspace}/${mesonBuildDir}`) 486 | if (!rebuild) { 487 | commands.push( 488 | new Command( 489 | 'flatpak', 490 | [ 491 | 'build', 492 | ...buildArgs, 493 | this.repoDir, 494 | 'meson', 495 | 'setup', 496 | '--prefix', 497 | '/app', 498 | mesonBuildDir, 499 | ...configOpts, 500 | ], 501 | { cwd: this.workspace }, 502 | ) 503 | ) 504 | } 505 | commands.push( 506 | new Command( 507 | 'flatpak', 508 | ['build', ...buildArgs, this.repoDir, 'ninja', '-C', mesonBuildDir], 509 | { cwd: this.workspace }, 510 | ) 511 | ) 512 | commands.push( 513 | new Command( 514 | 'flatpak', 515 | [ 516 | 'build', 517 | ...buildArgs, 518 | this.repoDir, 519 | 'meson', 520 | 'install', 521 | '-C', 522 | mesonBuildDir, 523 | ], 524 | { cwd: this.workspace }, 525 | ) 526 | ) 527 | return commands 528 | } 529 | 530 | getSimpleCommands(moduleName: string, buildCommands: string[], buildArgs: string[]): Command[] { 531 | return buildCommands.map((command) => { 532 | const commandArgs = command.replace('${FLATPAK_ID}', this.id()) 533 | .replace('${FLATPAK_ARCH}', arch()) 534 | .replace('${FLATPAK_DEST}', '/app') // We only support applications 535 | .replace('${FLATPAK_BUILDER_N_JOBS}', cpus().length.toString()) 536 | .replace('${FLATPAK_BUILDER_BUILDDIR}', `/run/build/${moduleName}`) 537 | .split(' ').filter((v) => !!v) 538 | return new Command( 539 | 'flatpak', 540 | ['build', ...buildArgs, this.repoDir, ...commandArgs], 541 | { cwd: this.workspace }, 542 | ) 543 | }) 544 | } 545 | 546 | async bundle(): Promise { 547 | const commands = [] 548 | await fs.rm(this.finializedRepoDir, { 549 | recursive: true, 550 | force: true, 551 | }) 552 | 553 | commands.push(new Command('cp', [ 554 | '-r', 555 | this.repoDir, 556 | this.finializedRepoDir, 557 | ])) 558 | 559 | commands.push(new Command('flatpak', [ 560 | 'build-finish', 561 | ...this.finishArgs(), 562 | `--command=${this.manifest.command}`, 563 | this.finializedRepoDir, 564 | ], { cwd: this.workspace })) 565 | 566 | commands.push(new Command('flatpak', [ 567 | 'build-export', 568 | this.ostreeRepoPath, 569 | this.finializedRepoDir, 570 | ], { cwd: this.workspace })) 571 | 572 | commands.push(new Command('flatpak', [ 573 | 'build-bundle', 574 | this.ostreeRepoPath, 575 | `${this.id()}.flatpak`, 576 | this.id(), 577 | ], { cwd: this.workspace })) 578 | 579 | return commands 580 | } 581 | 582 | async run(): Promise { 583 | return this.runInRepo([this.manifest.command, ...(this.manifest['x-run-args'] || [])].join(' '), false) 584 | } 585 | 586 | async runInRepo(shellCommand: string, mountExtensions: boolean, additionalEnvVars?: Map): Promise { 587 | const uid = process.geteuid ? process.geteuid() : 1000 588 | const appId = this.id() 589 | if (this.fontsArgs.length === 0) { 590 | this.fontsArgs = await getFontsArgs() 591 | } 592 | if (this.a11yBusArgs.length === 0) { 593 | this.a11yBusArgs = await getA11yBusArgs() 594 | } 595 | let args = [ 596 | 'build', 597 | '--with-appdir', 598 | '--allow=devel', 599 | `--bind-mount=/run/user/${uid}/doc=/run/user/${uid}/doc/by-app/${appId}`, 600 | ...this.finishArgs(), 601 | '--talk-name=org.freedesktop.portal.*', 602 | '--talk-name=org.a11y.Bus', 603 | ] 604 | args.push(...this.a11yBusArgs) 605 | 606 | const envVars = getHostEnv() 607 | 608 | if (additionalEnvVars !== undefined) { 609 | for (const [key, value] of additionalEnvVars) { 610 | envVars.set(key, value) 611 | } 612 | } 613 | 614 | for (const [key, value] of envVars) { 615 | args.push(`--env=${key}=${value}`) 616 | } 617 | 618 | if (mountExtensions) { 619 | args = args.concat(this.getPaths()) 620 | 621 | // Assume we might need network access by the executable 622 | args.push('--share=network') 623 | } 624 | args.push(...this.fontsArgs) 625 | 626 | args.push(this.repoDir) 627 | args.push(shellCommand) 628 | return new Command('flatpak', args, { cwd: this.workspace }) 629 | } 630 | 631 | async deleteRepoDir(): Promise { 632 | await fs.rm(this.repoDir, { 633 | recursive: true, 634 | force: true, 635 | }) 636 | } 637 | 638 | async overrideWorkspaceCommandConfig( 639 | section: string, 640 | configName: string, 641 | program: string, 642 | binaryPath?: string, 643 | additionalEnvVars?: Map, 644 | ): Promise { 645 | const commandPath = path.join(this.buildDir, `${program}.sh`) 646 | const command = await this.runInRepo(`${binaryPath || ''}${program}`, true, additionalEnvVars) 647 | await command.saveAsScript(commandPath) 648 | const commandPathSettingsValue = commandPath.replace(this.workspace, '${workspaceFolder}') 649 | await this.overrideWorkspaceConfig(section, configName, commandPathSettingsValue) 650 | } 651 | 652 | async overrideWorkspaceConfig( 653 | section: string, 654 | configName: string, 655 | value?: string | string[] | boolean 656 | ): Promise { 657 | const config = vscode.workspace.getConfiguration(section) 658 | await config.update(configName, value) 659 | } 660 | 661 | async restoreWorkspaceConfig( 662 | section: string, 663 | configName: string, 664 | ): Promise { 665 | await this.overrideWorkspaceConfig(section, configName, undefined) 666 | } 667 | } 668 | -------------------------------------------------------------------------------- /src/manifestManager.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from './manifest' 2 | import { exists } from './utils' 3 | import * as fs from 'fs/promises' 4 | import * as vscode from 'vscode' 5 | import { WorkspaceState } from './workspaceState' 6 | import { EXTENSION_ID } from './extension' 7 | import { isDeepStrictEqual } from 'util' 8 | import { ManifestMap } from './manifestMap' 9 | import { findManifests, MANIFEST_PATH_GLOB_PATTERN, parseManifest } from './manifestUtils' 10 | 11 | interface ManifestQuickPickItem { 12 | label: string, 13 | detail: string, 14 | manifest: Manifest 15 | } 16 | 17 | export class ManifestManager implements vscode.Disposable { 18 | private readonly statusItem: vscode.StatusBarItem 19 | private readonly workspaceState: WorkspaceState 20 | private readonly manifestWatcher: vscode.FileSystemWatcher 21 | private manifests?: ManifestMap 22 | private activeManifest: Manifest | null 23 | 24 | private readonly _onDidActiveManifestChanged = new vscode.EventEmitter<[Manifest | null, boolean]>() 25 | readonly onDidActiveManifestChanged = this._onDidActiveManifestChanged.event 26 | 27 | private readonly _onDidRequestRebuild = new vscode.EventEmitter() 28 | readonly onDidRequestRebuild = this._onDidRequestRebuild.event 29 | 30 | constructor(workspaceState: WorkspaceState) { 31 | this.workspaceState = workspaceState 32 | this.activeManifest = null 33 | 34 | this.manifestWatcher = vscode.workspace.createFileSystemWatcher(MANIFEST_PATH_GLOB_PATTERN) 35 | this.manifestWatcher.onDidCreate(async (newUri) => { 36 | console.log(`Possible manifest created at ${newUri.fsPath}`) 37 | 38 | try { 39 | const newManifest = await parseManifest(newUri) 40 | if (newManifest === null) { 41 | return 42 | } 43 | 44 | const manifests = await this.getManifests() 45 | manifests.add(newManifest) 46 | } catch (err) { 47 | console.warn(`Failed to parse manifest at ${newUri.fsPath}`) 48 | } 49 | }) 50 | this.manifestWatcher.onDidChange(async (uri) => { 51 | console.log(`Possible manifest modified at ${uri.fsPath}`) 52 | await this.updateManifest(uri) 53 | }) 54 | this.manifestWatcher.onDidDelete(async (deletedUri) => { 55 | console.log(`Possible manifest deleted at ${deletedUri.fsPath}`) 56 | 57 | const manifests = await this.getManifests() 58 | manifests.delete(deletedUri) 59 | }) 60 | 61 | vscode.workspace.onDidChangeConfiguration(async (event) => { 62 | if (event.affectsConfiguration('flatpak-vscode')) { 63 | await this.refreshManifests() 64 | } 65 | }) 66 | 67 | this.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left) 68 | this.updateStatusItem() 69 | } 70 | 71 | async loadLastActiveManifest(): Promise { 72 | let lastActiveManifestUri = this.workspaceState.getActiveManifestUri() 73 | 74 | if (lastActiveManifestUri !== undefined && !await exists(lastActiveManifestUri.fsPath)) { 75 | lastActiveManifestUri = undefined 76 | } 77 | 78 | if (lastActiveManifestUri === undefined) { 79 | const defaultManifest = await this.findDefaultManifest() 80 | if (defaultManifest !== undefined) { 81 | console.log(`Manifest set as default at ${defaultManifest.uri.fsPath}`) 82 | await this.setActiveManifest(defaultManifest, true) 83 | } 84 | return 85 | } 86 | 87 | const manifests = await this.getManifests() 88 | const lastActiveManifest = manifests.get(lastActiveManifestUri) 89 | if (lastActiveManifest === undefined) { 90 | return 91 | } 92 | 93 | console.log(`Found last active manifest uri at ${lastActiveManifestUri.fsPath}`) 94 | await this.setActiveManifest(lastActiveManifest, true) 95 | } 96 | 97 | async findDefaultManifest(): Promise { 98 | const manifests = await this.getManifests() 99 | 100 | // If there is only one manifest to select, select it automatically. 101 | const firstManifest = manifests.getFirstItem() 102 | if (manifests.size() === 1 && firstManifest) { 103 | console.log('Found only one valid manifest') 104 | return firstManifest 105 | } 106 | 107 | // If some manifest contains '.Devel.' in its filename, select it automatically. 108 | for (const manifest of manifests) { 109 | if (manifest.uri.fsPath.includes('.Devel.')) { 110 | console.log('Found a manifest that contains ".Devel." in its filename') 111 | return manifest 112 | } 113 | } 114 | 115 | return undefined 116 | } 117 | 118 | async getManifests(): Promise { 119 | if (this.manifests === undefined) { 120 | console.log('Looking for potential Flatpak manifests') 121 | this.manifests = await findManifests() 122 | 123 | this.tryShowStatusItem() 124 | this.manifests.onDidItemsChanged(() => { 125 | this.tryShowStatusItem() 126 | }) 127 | this.manifests.onDidItemAdded(async (manifest) => { 128 | // If that manifest is the first valid manifest, select it automatically. 129 | if (this.manifests!.size() === 1) { 130 | console.log(`Found the first valid manifest at ${manifest.uri.fsPath}. Setting it as active.`) 131 | await this.setActiveManifest(manifest, true) 132 | } 133 | }) 134 | this.manifests.onDidItemDeleted(async (deletedUri) => { 135 | if (deletedUri.fsPath === this.activeManifest?.uri.fsPath) { 136 | // If current active manifest is deleted and there is only one manifest 137 | // left, select that manifest automatically. 138 | const firstManifest = this.manifests!.getFirstItem() 139 | if (this.manifests!.size() === 1 && firstManifest) { 140 | console.log(`Found only one valid manifest. Setting active manifest to ${firstManifest.uri.fsPath}`) 141 | await this.setActiveManifest(firstManifest, false) 142 | } else { 143 | await this.setActiveManifest(null, false) 144 | } 145 | } 146 | }) 147 | } 148 | 149 | return this.manifests 150 | } 151 | 152 | async refreshManifests(): Promise { 153 | if (this.manifests !== undefined) { 154 | console.log('Refreshing Flatpak manifests') 155 | const newManifests = await findManifests() 156 | this.manifests.update(newManifests) 157 | } 158 | } 159 | 160 | /** 161 | * Like `getActiveManifestUnchecked` but throws an error when the active manifest contains error. 162 | * 163 | * @returns active manifest 164 | */ 165 | async getActiveManifest(): Promise { 166 | const manifest = await this.getActiveManifestUnchecked() 167 | 168 | const error = manifest.checkForError() 169 | if (error !== null) { 170 | throw Error(`Active Flatpak manifest has error: ${error.message}`) 171 | } 172 | 173 | return manifest 174 | } 175 | 176 | /** 177 | * Convenience function to get the active manifest and try handle if it doesn't exist. 178 | * 179 | * This throws an error if a null active manifest is unhandled. 180 | * 181 | * @returns active manifest 182 | */ 183 | async getActiveManifestUnchecked(): Promise { 184 | let ret = this.activeManifest 185 | 186 | if (ret === null) { 187 | const selectedManifest = await this.selectManifest() 188 | if (selectedManifest === null) { 189 | throw Error('No Flatpak manifest was selected.') 190 | } 191 | ret = selectedManifest 192 | } 193 | 194 | return ret 195 | } 196 | 197 | isActiveManifest(manifest: Manifest | null): boolean { 198 | return isDeepStrictEqual(this.activeManifest, manifest) 199 | } 200 | 201 | /** 202 | * Sets the active manifest 203 | * @param manifest Manifest to be set 204 | * @param isLastActive Whether if the manifest was loaded from stored ActiveManifestUri 205 | */ 206 | private async setActiveManifest(manifest: Manifest | null, isLastActive: boolean): Promise { 207 | if (this.isActiveManifest(manifest)) { 208 | return 209 | } 210 | 211 | this.activeManifest = manifest 212 | this._onDidActiveManifestChanged.fire([manifest, isLastActive]) 213 | 214 | console.log(`Current active manifest: ${manifest?.uri.fsPath || 'null'}`) 215 | 216 | await this.workspaceState.setActiveManifestUri(manifest?.uri) 217 | 218 | this.updateStatusItem() 219 | 220 | if (manifest === null) { 221 | return 222 | } 223 | 224 | // Ensure that build directory of active manifest exists 225 | await fs.mkdir(manifest.buildDir, { recursive: true }) 226 | } 227 | 228 | /** 229 | * Update the manifest at the specified uri 230 | * @param uri Where the concerned manifest is stored 231 | */ 232 | private async updateManifest(uri: vscode.Uri) { 233 | const manifests = await this.getManifests() 234 | const oldManifest = manifests.get(uri) 235 | 236 | if (oldManifest === undefined) { 237 | return 238 | } 239 | 240 | try { 241 | const updatedManifest = await parseManifest(uri) 242 | if (updatedManifest === null) { 243 | return 244 | } 245 | 246 | // The path has not changed so this will only update the content of 247 | // the manifest. 248 | manifests.add(updatedManifest) 249 | 250 | if (uri.fsPath === this.activeManifest?.uri.fsPath) { 251 | await this.setActiveManifest(updatedManifest, false) 252 | } 253 | 254 | const hasModifiedModules = !isDeepStrictEqual(oldManifest.manifest.modules, updatedManifest.manifest.modules) 255 | const hasModifiedBuildOptions = !isDeepStrictEqual(oldManifest.manifest['build-options'], updatedManifest.manifest['build-options']) 256 | 257 | if (hasModifiedModules || hasModifiedBuildOptions) { 258 | console.log('Updated manifest has modified modules or build-options. Requesting a rebuild') 259 | this._onDidRequestRebuild.fire(updatedManifest) 260 | } 261 | } catch (err) { 262 | console.warn(`Failed to parse manifest at ${uri.fsPath}`) 263 | } 264 | } 265 | 266 | /** 267 | * Update the stored active manifest if user selects something and return the selected manifest. 268 | * 269 | * Throws an error if there are no discovered manifests 270 | * 271 | * @returns the selected manifest 272 | */ 273 | async selectManifest(): Promise { 274 | const manifests = await this.getManifests() 275 | 276 | if (manifests.isEmpty()) { 277 | throw Error('No Flatpak manifest found in this workspace.') 278 | } 279 | 280 | const quickPickItems: ManifestQuickPickItem[] = [] 281 | manifests.forEach((manifest) => { 282 | const labelPrefix = manifest.uri.fsPath === this.activeManifest?.uri.fsPath 283 | ? '$(pass-filled)' : '$(circle-large-outline)' 284 | 285 | quickPickItems.push({ 286 | label: `${labelPrefix} ${manifest.id()}`, 287 | detail: manifest.uri.fsPath, 288 | manifest: manifest, 289 | }) 290 | }) 291 | 292 | const selectedItem = await vscode.window.showQuickPick(quickPickItems, { 293 | canPickMany: false, 294 | matchOnDescription: true, 295 | matchOnDetail: true, 296 | }) 297 | 298 | if (selectedItem === undefined) { 299 | return null 300 | } 301 | 302 | await this.setActiveManifest(selectedItem.manifest, false) 303 | return selectedItem.manifest 304 | } 305 | 306 | private tryShowStatusItem() { 307 | if (this.manifests !== undefined && !this.manifests.isEmpty()) { 308 | this.statusItem.show() 309 | } 310 | } 311 | 312 | private updateStatusItem() { 313 | const manifestError = this.activeManifest?.checkForError() || null 314 | 315 | if (this.activeManifest === null) { 316 | this.statusItem.text = '$(package) No active manifest' 317 | this.statusItem.command = `${EXTENSION_ID}.select-manifest` 318 | this.statusItem.tooltip = 'Select manifest' 319 | this.statusItem.color = undefined 320 | } else if (manifestError !== null) { 321 | this.statusItem.text = `$(package) ${this.activeManifest.id()}` 322 | this.statusItem.command = `${EXTENSION_ID}.show-active-manifest` 323 | this.statusItem.tooltip = manifestError.message 324 | this.statusItem.color = new vscode.ThemeColor('notificationsErrorIcon.foreground') 325 | } else { 326 | this.statusItem.text = `$(package) ${this.activeManifest.id()}` 327 | this.statusItem.command = `${EXTENSION_ID}.show-active-manifest` 328 | this.statusItem.tooltip = this.activeManifest.uri.fsPath 329 | this.statusItem.color = undefined 330 | } 331 | } 332 | 333 | dispose(): void { 334 | this.statusItem.dispose() 335 | this.manifestWatcher.dispose() 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/manifestMap.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from './manifest' 2 | import * as vscode from 'vscode' 3 | 4 | export class ManifestMap implements Iterable { 5 | private readonly inner: Map 6 | 7 | private readonly _onDidItemsChanged = new vscode.EventEmitter() 8 | readonly onDidItemsChanged = this._onDidItemsChanged.event 9 | private readonly _onDidItemAdded = new vscode.EventEmitter() 10 | readonly onDidItemAdded = this._onDidItemAdded.event 11 | private readonly _onDidItemDeleted = new vscode.EventEmitter() 12 | readonly onDidItemDeleted = this._onDidItemDeleted.event 13 | 14 | constructor() { 15 | this.inner = new Map() 16 | } 17 | 18 | [Symbol.iterator](): IterableIterator { 19 | return this.inner.values() 20 | } 21 | 22 | add(manifest: Manifest): void { 23 | const isAdded = !this.inner.has(manifest.uri.fsPath) 24 | 25 | this.inner.set(manifest.uri.fsPath, manifest) 26 | this._onDidItemsChanged.fire() 27 | 28 | if (isAdded) { 29 | this._onDidItemAdded.fire(manifest) 30 | } 31 | } 32 | 33 | delete(uri: vscode.Uri): boolean { 34 | const isDeleted = this.inner.delete(uri.fsPath) 35 | 36 | if (isDeleted) { 37 | this._onDidItemsChanged.fire() 38 | this._onDidItemDeleted.fire(uri) 39 | } 40 | 41 | return isDeleted 42 | } 43 | 44 | update(other: ManifestMap): void { 45 | for (const manifest of this) { 46 | if (!other.inner.has(manifest.uri.fsPath)) { 47 | this.delete(manifest.uri) 48 | } 49 | } 50 | 51 | for (const manifest of other) { 52 | this.add(manifest) 53 | } 54 | } 55 | 56 | get(uri: vscode.Uri): Manifest | undefined { 57 | return this.inner.get(uri.fsPath) 58 | } 59 | 60 | getFirstItem(): Manifest | undefined { 61 | return Array.from(this.inner.values())[0] 62 | } 63 | 64 | size(): number { 65 | return this.inner.size 66 | } 67 | 68 | isEmpty(): boolean { 69 | return this.size() === 0 70 | } 71 | 72 | forEach(callbackFn: (manifest: Manifest) => void): void { 73 | this.inner.forEach((value) => callbackFn(value)) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/manifestUtils.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from './manifest' 2 | import { ManifestSchema } from './flatpak.types' 3 | import { ManifestMap } from './manifestMap' 4 | import * as JSONC from 'jsonc-parser' 5 | import { Uri, workspace } from 'vscode' 6 | import * as yaml from 'js-yaml' 7 | import * as path from 'path' 8 | import { getAvailable } from './flatpakUtils' 9 | 10 | /** 11 | * VSCode specification compliant glob pattern to look up for 12 | * possible Flatpak manifests. 13 | */ 14 | export const MANIFEST_PATH_GLOB_PATTERN = '**/*.{json,yaml,yml}' 15 | 16 | /** 17 | * Finds possible manifests in workspace then deserialize them. 18 | * @returns List of Flatpak Manifest 19 | */ 20 | export async function findManifests(): Promise { 21 | // Always exclude the directories we generate. 22 | const excludeBaseDirs = ['.flatpak', '_build'] 23 | const excludeConfigDirs = workspace.getConfiguration('flatpak-vscode').get('excludeManifestDirs') 24 | const excludeDirs = excludeBaseDirs.concat(excludeConfigDirs ?? []).join(',') 25 | 26 | const uris: Uri[] = await workspace.findFiles( 27 | MANIFEST_PATH_GLOB_PATTERN, 28 | `**/{${excludeDirs}}/*`, 29 | 1000 30 | ) 31 | const manifests = new ManifestMap() 32 | for (const uri of uris) { 33 | try { 34 | const manifest = await parseManifest(uri) 35 | if (manifest) { 36 | manifests.add(manifest) 37 | } 38 | } catch (err) { 39 | console.warn(`Failed to parse the manifest at ${uri.fsPath}`) 40 | } 41 | } 42 | return manifests 43 | } 44 | 45 | /** 46 | * Parses a manifest. It also considers the application ID before reading and parsing. 47 | * @param uri Path to the manifest 48 | * @returns A valid FlatpakManifest, otherwise null 49 | */ 50 | export async function parseManifest(uri: Uri): Promise { 51 | console.log(`Trying to parse potential Flatpak manifest ${uri.fsPath}`) 52 | const applicationId = path.parse(uri.fsPath).name 53 | if (!isValidDbusName(applicationId)) { 54 | return null 55 | } 56 | 57 | const textDocument = await workspace.openTextDocument(uri) 58 | const data = textDocument.getText() 59 | 60 | let manifest = null 61 | switch (textDocument.languageId) { 62 | // We assume json files might be commented 63 | // because flatpak-builder uses json-glib which accepts commented json files 64 | case 'json': 65 | case 'jsonc': 66 | manifest = JSONC.parse(data) as ManifestSchema 67 | break 68 | case 'yaml': 69 | manifest = yaml.load(data) as ManifestSchema 70 | break 71 | default: 72 | // This should not be triggered since only json,yaml,yml are passed in findFiles 73 | console.error(`Trying to parse a document with invalid language id: ${textDocument.languageId}`) 74 | break 75 | } 76 | 77 | if (manifest === null) { 78 | return null 79 | } 80 | 81 | if (isValidManifest(manifest)) { 82 | return new Manifest( 83 | uri, 84 | manifest, 85 | ) 86 | } 87 | 88 | return null 89 | } 90 | 91 | /** 92 | * Check if a DBus name follows the 93 | * [DBus specification](https://dbus.freedesktop.org/doc/dbus-specification.html). 94 | * @param name the DBus name 95 | */ 96 | export function isValidDbusName(name: string): boolean { 97 | // The length must be > 0 but must also be <= 255 98 | if (name.length === 0 || name.length > 255) { 99 | return false 100 | } 101 | 102 | const elements = name.split('.') 103 | 104 | // Should have at least two elements; thus, it has at least one period 105 | if (elements.length < 2) { 106 | return false 107 | } 108 | 109 | const isEveryElementValid = elements.every((element) => { 110 | // Must not be empty; thus, not having two consecutive periods 111 | // This also covers that the name must not start or end with a period 112 | return element.length !== 0 113 | // Must also not have a number as first character 114 | && !isNumber(element.charAt(0)) 115 | // Element characters must only contain 0-9, a-z, A-Z, hyphens, or underscores 116 | && [...element].every((char) => isValidDbusNameCharacter(char)) 117 | }) 118 | 119 | if (!isEveryElementValid) { 120 | return false 121 | } 122 | 123 | return true 124 | } 125 | 126 | /** 127 | * Checks whether a character is a valid dbus name character 128 | * @param char The character to check 129 | * @returns whether if the character is a valid dbus name character 130 | */ 131 | function isValidDbusNameCharacter(char: string): boolean { 132 | return isNumber(char) 133 | || (char >= 'A' && char <= 'Z') 134 | || (char >= 'a' && char <= 'z') 135 | || (char === '_') 136 | || (char === '-') 137 | } 138 | 139 | /** 140 | * Checks whether a character can be parsed to a number from 0 to 9 141 | * @param char A character 142 | * @returns Whether the character can be parsed to a number 143 | */ 144 | function isNumber(char: string): boolean { 145 | return char >= '0' && char <= '9' 146 | } 147 | 148 | function isValidManifest(manifest: ManifestSchema): boolean { 149 | const hasId = (manifest.id || manifest['app-id']) !== undefined 150 | // FIXME ManifestSchema.modules can be undefined, even it is not marked as that. 151 | // It was not detected before as we return `null` on `parseManifest` early before 152 | // having a chance to access the undefined `modules`. 153 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 154 | const hasModules = manifest.modules !== undefined 155 | return hasId && hasModules 156 | } 157 | 158 | /** 159 | * Check for runtimes specified in the manifest but are not installed. 160 | * @param manifest Manifest to check 161 | * @returns List of runtimes that are not installed 162 | */ 163 | export function checkForMissingRuntimes(manifest: Manifest): string[] { 164 | const runtimeVersion = manifest.manifest['runtime-version'] 165 | const missingRuntimes = new Set([manifest.manifest.runtime, manifest.manifest.sdk]) 166 | const missingSdkExtensions = new Set(manifest.manifest['sdk-extensions']) 167 | 168 | for (const availableRuntime of getAvailable('runtime')) { 169 | if (runtimeVersion === availableRuntime.version) { 170 | if (missingRuntimes.delete(availableRuntime.id)) { 171 | continue 172 | } 173 | } 174 | 175 | // TODO also check the version 176 | missingSdkExtensions.delete(availableRuntime.id) 177 | } 178 | 179 | const ret = [...missingSdkExtensions] 180 | for (const missingRuntime of missingRuntimes) { 181 | ret.push(`${missingRuntime}//${runtimeVersion}`) 182 | } 183 | return ret 184 | } 185 | -------------------------------------------------------------------------------- /src/migration.ts: -------------------------------------------------------------------------------- 1 | // TODO remove in next releases 2 | 3 | import * as vscode from 'vscode' 4 | import { WorkspaceState } from './workspaceState' 5 | import * as fs from 'fs/promises' 6 | import { exists } from './utils' 7 | 8 | interface LegacyState { 9 | selectedManifest: { uri: { path: string } } | null 10 | pipeline: { 11 | initialized: boolean 12 | dependencies: { 13 | updated: boolean 14 | built: boolean 15 | } 16 | application: { 17 | built: boolean 18 | } 19 | } 20 | } 21 | 22 | /** 23 | * Migrate persistent workspace state from the use of `pipeline.json` to Memento API 24 | * @param workspaceState Instance of the workspace state 25 | */ 26 | export async function migrateStateToMemento(workspaceState: WorkspaceState): Promise { 27 | const workspaceFolders = vscode.workspace.workspaceFolders 28 | 29 | if (workspaceFolders === undefined) { 30 | return 31 | } 32 | 33 | const stateFileUri = vscode.Uri.joinPath(workspaceFolders[0].uri, '.flatpak', 'pipeline.json') 34 | 35 | if (!await exists(stateFileUri.fsPath)) { 36 | return 37 | } 38 | 39 | try { 40 | const stateFile = await vscode.workspace.openTextDocument(stateFileUri) 41 | const legacyState = JSON.parse(stateFile.getText()) as LegacyState 42 | 43 | if (legacyState.selectedManifest === null) { 44 | // There was no selected manifest so it doesn't makes sense to restore state. 45 | return 46 | } 47 | 48 | if (!await exists(legacyState.selectedManifest.uri.path)) { 49 | // If the old selected manifest doesn't exist anymore, it doesn't make 50 | // sense either to restore state. 51 | return 52 | } 53 | 54 | await workspaceState.setActiveManifestUri(vscode.Uri.file(legacyState.selectedManifest.uri.path)) 55 | await workspaceState.setDependenciesUpdated(legacyState.pipeline.dependencies.updated) 56 | await workspaceState.setDependenciesBuilt(legacyState.pipeline.dependencies.built) 57 | await workspaceState.setApplicationBuilt(legacyState.pipeline.application.built) 58 | 59 | await fs.rm(stateFileUri.fsPath) 60 | 61 | console.info('Successfully migrated from `pipeline.json` to `Memento`') 62 | } catch (err) { 63 | console.warn(`Failed to migrate to memento: ${err as string}`) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/nodePty.ts: -------------------------------------------------------------------------------- 1 | // See also: 2 | // https://github.com/microsoft/vscode/issues/84439 3 | // https://code.visualstudio.com/api/advanced-topics/remote-extensions#persisting-secrets 4 | // TODO: Replace with more reliable way to import node-pty 5 | 6 | import * as vscode from 'vscode' 7 | 8 | declare const WEBPACK_REQUIRE: typeof require 9 | declare const NON_WEBPACK_REQUIRE: typeof require 10 | 11 | const pty = getCoreNodeModule('node-pty') as typeof import('node-pty') 12 | 13 | export type IPty = import('node-pty').IPty 14 | export const spawn: typeof import('node-pty').spawn = pty.spawn 15 | 16 | /** 17 | * Returns a node module installed with VSCode, or null if it fails. 18 | */ 19 | function getCoreNodeModule(moduleName: string) { 20 | const r = typeof WEBPACK_REQUIRE === 'function' ? NON_WEBPACK_REQUIRE : require 21 | try { 22 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 23 | return r(`${vscode.env.appRoot}/node_modules.asar/${moduleName}`) 24 | } catch (err) { 25 | console.error(`Failed to getCoreNodeModule '${moduleName}': ${err as string}`) 26 | } 27 | 28 | try { 29 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 30 | return r(`${vscode.env.appRoot}/node_modules/${moduleName}`) 31 | } catch (err) { 32 | console.error(`Failed to getCoreNodeModule '${moduleName}': ${err as string}`) 33 | } 34 | 35 | return null 36 | } 37 | -------------------------------------------------------------------------------- /src/outputTerminal.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | const RESET_COLOR = '\x1b[0m' 4 | 5 | export class OutputTerminal implements vscode.Disposable { 6 | private inner?: vscode.Terminal 7 | private isOpen: boolean 8 | private _dimensions?: vscode.TerminalDimensions 9 | private readonly pty: vscode.Pseudoterminal 10 | private readonly writeEmitter: vscode.EventEmitter 11 | 12 | private readonly _onDidOpen = new vscode.EventEmitter() 13 | private readonly onDidOpen = this._onDidOpen.event 14 | 15 | private readonly _onDidClose = new vscode.EventEmitter() 16 | readonly onDidClose = this._onDidClose.event 17 | 18 | private readonly _onDidSetDimensions = new vscode.EventEmitter() 19 | readonly onDidSetDimensions = this._onDidSetDimensions.event 20 | 21 | constructor() { 22 | this.isOpen = false 23 | this.writeEmitter = new vscode.EventEmitter() 24 | this.pty = { 25 | open: (dimensions) => { 26 | this._dimensions = dimensions 27 | this.isOpen = true 28 | this._onDidOpen.fire() 29 | }, 30 | setDimensions: (dimensions) => { 31 | this._dimensions = dimensions 32 | this._onDidSetDimensions.fire(dimensions) 33 | }, 34 | close: () => { 35 | this.isOpen = false 36 | this._onDidClose.fire() 37 | this.inner?.dispose() 38 | this.inner = undefined 39 | this._dimensions = undefined 40 | }, 41 | onDidWrite: this.writeEmitter.event, 42 | } 43 | } 44 | 45 | get dimensions(): vscode.TerminalDimensions | undefined { 46 | return this._dimensions 47 | } 48 | 49 | append(content: string): void { 50 | this.writeEmitter.fire(content) 51 | } 52 | 53 | appendLine(content: string): void { 54 | this.append(`${content}\r\n`) 55 | } 56 | 57 | appendError(message: string): void { 58 | const boldRed = '\x1b[1;31m' 59 | this.appendLine(`\r${boldRed}>>> ${message}${RESET_COLOR}`) 60 | } 61 | 62 | appendMessage(message: string): void { 63 | const boldWhite = '\x1b[1;37m' 64 | this.appendLine(`\r${boldWhite}>>> ${message}${RESET_COLOR}`) 65 | } 66 | 67 | async show(preserveFocus?: boolean): Promise { 68 | if (this.inner === undefined) { 69 | this.inner = vscode.window.createTerminal({ 70 | name: 'Flatpak Output Terminal', 71 | iconPath: new vscode.ThemeIcon('package'), 72 | pty: this.pty 73 | }) 74 | } 75 | this.inner.show(preserveFocus) 76 | 77 | await this.waitToOpen() 78 | } 79 | 80 | hide(): void { 81 | this.inner?.hide() 82 | } 83 | 84 | dispose() { 85 | this.inner?.dispose() 86 | this.pty.close() 87 | } 88 | 89 | private async waitToOpen(): Promise { 90 | if (this.isOpen) { 91 | return 92 | } 93 | 94 | return new Promise((resolve) => { 95 | this.onDidOpen(() => resolve()) 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/runner.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { TaskMode, taskModeAsStatus } from './taskMode' 3 | import { Canceled, Command } from './command' 4 | import { OutputTerminal } from './outputTerminal' 5 | import { RunnerStatusItem } from './runnerStatusItem' 6 | import { EXTENSION_ID } from './extension' 7 | 8 | export class RunnerError extends Error { 9 | constructor(mode: TaskMode, message: string) { 10 | super(`Failed to execute ${mode}: ${message}`) 11 | } 12 | } 13 | 14 | export class Runner implements vscode.Disposable { 15 | private readonly outputTerminal: OutputTerminal 16 | private readonly statusItem: RunnerStatusItem 17 | private isActive: boolean 18 | private currentCommandHandler?: vscode.CancellationTokenSource 19 | 20 | constructor(outputTerminal: OutputTerminal) { 21 | this.outputTerminal = outputTerminal 22 | this.outputTerminal.onDidClose(() => this.stop()) 23 | 24 | this.statusItem = new RunnerStatusItem() 25 | this.isActive = false 26 | } 27 | 28 | /** 29 | * Throws an error if this is active 30 | */ 31 | ensureIdle() { 32 | if (this.isActive) { 33 | throw new Error('Stop the currently running task first.') 34 | } 35 | } 36 | 37 | /** 38 | * Run the commands one after another in order. Errors on a command would 39 | * inhibit other queued commands from running. 40 | * @param commands The commands to be executed 41 | * @param mode Execution context 42 | */ 43 | async execute(commands: Command[], mode: TaskMode): Promise { 44 | await this.outputTerminal.show(true) 45 | 46 | await this.setActive(true) 47 | this.statusItem.setStatus(taskModeAsStatus(mode)) 48 | 49 | try { 50 | for (const command of commands) { 51 | this.outputTerminal.appendMessage(command.toString()) 52 | 53 | this.currentCommandHandler = new vscode.CancellationTokenSource() 54 | await command.spawn(this.outputTerminal, this.currentCommandHandler.token) 55 | } 56 | } catch (err) { 57 | // Don't error when stopped the application using stop button 58 | if (mode === TaskMode.run && err instanceof Canceled) { 59 | return 60 | } 61 | 62 | this.onError(mode, err as string) 63 | throw new RunnerError(mode, err as string) 64 | } finally { 65 | this.currentCommandHandler = undefined 66 | this.statusItem.setStatus(null) 67 | await this.setActive(false) 68 | } 69 | } 70 | 71 | /** 72 | * Cancel the running and queued commands 73 | */ 74 | async stop(): Promise { 75 | await this.outputTerminal.show(true) 76 | 77 | if (this.currentCommandHandler !== undefined) { 78 | this.currentCommandHandler.cancel() 79 | this.currentCommandHandler = undefined 80 | } 81 | } 82 | 83 | async dispose() { 84 | await this.stop() 85 | this.statusItem.dispose() 86 | } 87 | 88 | private async setActive(value: boolean): Promise { 89 | await vscode.commands.executeCommand('setContext', 'flatpakRunnerActive', value) 90 | this.isActive = value 91 | } 92 | 93 | private onError(mode: TaskMode, message: string): void { 94 | this.outputTerminal.appendError(message) 95 | 96 | this.statusItem.setStatus({ 97 | type: 'error', 98 | isOperation: false, 99 | title: `Failed to execute ${mode}`, 100 | clickable: { 101 | command: `${EXTENSION_ID}.show-output-terminal`, 102 | tooltip: 'Show output' 103 | }, 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/runnerStatusItem.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | export interface Clickable { 4 | command: string, 5 | tooltip: string, 6 | } 7 | 8 | export interface Status { 9 | type: 'ok' | 'error' 10 | title: string 11 | /** 12 | * Whether to show a spinning icon 13 | */ 14 | isOperation: boolean, 15 | clickable: Clickable | null 16 | } 17 | 18 | export class RunnerStatusItem implements vscode.Disposable { 19 | private readonly inner: vscode.StatusBarItem 20 | 21 | constructor() { 22 | this.inner = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left) 23 | } 24 | 25 | setStatus(status: Status | null): void { 26 | if (status === null) { 27 | this.inner.hide() 28 | return 29 | } 30 | 31 | let icon = '' 32 | 33 | switch (status.type) { 34 | case 'ok': 35 | this.inner.color = undefined 36 | break 37 | case 'error': 38 | this.inner.color = new vscode.ThemeColor('notificationsErrorIcon.foreground') 39 | icon = '$(error) ' 40 | break 41 | } 42 | 43 | this.inner.command = status.clickable?.command 44 | this.inner.tooltip = status.clickable?.tooltip 45 | 46 | if (status.isOperation) { 47 | icon = '$(sync~spin) ' 48 | } 49 | 50 | this.inner.text = `${icon} ${status.title}` 51 | this.inner.show() 52 | } 53 | 54 | dispose(): void { 55 | this.inner.dispose() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/taskMode.ts: -------------------------------------------------------------------------------- 1 | import { EXTENSION_ID } from './extension' 2 | import { Status } from './runnerStatusItem' 3 | 4 | export enum TaskMode { 5 | buildInit = 'build-init', 6 | updateDeps = 'update-deps', 7 | buildDeps = 'build-deps', 8 | buildApp = 'build-app', 9 | rebuild = 'rebuild', 10 | stop = 'stop', 11 | run = 'run', 12 | export = 'export', 13 | clean = 'clean', 14 | } 15 | 16 | export function taskModeAsStatus(taskMode: TaskMode): Status { 17 | let title 18 | switch (taskMode) { 19 | case TaskMode.buildInit: 20 | title = 'Initializing build environment' 21 | break 22 | case TaskMode.updateDeps: 23 | title = 'Updating application dependencies' 24 | break 25 | case TaskMode.buildDeps: 26 | title = 'Building application dependencies' 27 | break 28 | case TaskMode.buildApp: 29 | title = 'Building application' 30 | break 31 | case TaskMode.rebuild: 32 | title = 'Rebuilding application' 33 | break 34 | case TaskMode.stop: 35 | title = 'Stopping' 36 | break 37 | case TaskMode.run: 38 | title = 'Running application' 39 | break 40 | case TaskMode.export: 41 | title = 'Exporting bundle' 42 | break 43 | case TaskMode.clean: 44 | title = 'Cleaning build environment' 45 | break 46 | } 47 | 48 | return { 49 | title, 50 | type: 'ok', 51 | isOperation: true, 52 | clickable: { 53 | command: `${EXTENSION_ID}.show-output-terminal`, 54 | tooltip: 'Show output' 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/test/assets/.has.invalid.AppId.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": ".has.invalid.AppId", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "41", 5 | "sdk": "org.gnome.Sdk", 6 | "command": "app", 7 | "finish-args": [ 8 | "--share=ipc", 9 | "--socket=fallback-x11", 10 | "--socket=wayland", 11 | "--device=dri" 12 | ], 13 | "cleanup": [ 14 | "/include", 15 | "/lib/pkgconfig", 16 | "/man", 17 | "/share/doc", 18 | "/share/gtk-doc", 19 | "/share/man", 20 | "/share/pkgconfig", 21 | "*.la", 22 | "*.a" 23 | ], 24 | "modules": [ 25 | { 26 | "name": "app", 27 | "builddir": true, 28 | "buildsystem": "meson", 29 | "sources": [ 30 | { 31 | "type": "dir", 32 | "path": "." 33 | } 34 | ] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/test/assets/has.missing.AppId.json: -------------------------------------------------------------------------------- 1 | { 2 | "runtime": "org.gnome.Platform", 3 | "runtime-version": "41", 4 | "sdk": "org.gnome.Sdk", 5 | "command": "app", 6 | "finish-args": [ 7 | "--share=ipc", 8 | "--socket=fallback-x11", 9 | "--socket=wayland", 10 | "--device=dri" 11 | ], 12 | "cleanup": [ 13 | "/include", 14 | "/lib/pkgconfig", 15 | "/man", 16 | "/share/doc", 17 | "/share/gtk-doc", 18 | "/share/man", 19 | "/share/pkgconfig", 20 | "*.la", 21 | "*.a" 22 | ], 23 | "modules": [ 24 | { 25 | "name": "app", 26 | "builddir": true, 27 | "buildsystem": "meson", 28 | "sources": [ 29 | { 30 | "type": "dir", 31 | "path": "." 32 | } 33 | ] 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/test/assets/has.missing.Modules.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "org.valid.Manifest", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "41", 5 | "sdk": "org.gnome.Sdk", 6 | "command": "app", 7 | "finish-args": [ 8 | "--share=ipc", 9 | "--socket=fallback-x11", 10 | "--socket=wayland", 11 | "--device=dri" 12 | ], 13 | "cleanup": [ 14 | "/include", 15 | "/lib/pkgconfig", 16 | "/man", 17 | "/share/doc", 18 | "/share/gtk-doc", 19 | "/share/man", 20 | "/share/pkgconfig", 21 | "*.la", 22 | "*.a" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/test/assets/org.gnome.Screenshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id" : "org.gnome.Screenshot", 3 | "runtime" : "org.gnome.Platform", 4 | "runtime-version" : "master", 5 | "sdk" : "org.gnome.Sdk", 6 | "command" : "gnome-screenshot", 7 | "x-run-args" : [ 8 | "--interactive" 9 | ], 10 | "finish-args" : [ 11 | "--share=ipc", 12 | "--socket=x11", 13 | "--socket=wayland", 14 | "--socket=pulseaudio", 15 | "--talk-name=org.gnome.Shell.Screenshot", 16 | "--filesystem=home" 17 | ], 18 | "cleanup" : [ 19 | "/include", 20 | "/lib/pkgconfig", 21 | "/share/pkgconfig", 22 | "/share/aclocal", 23 | "/man", 24 | "/share/man", 25 | "/share/gtk-doc", 26 | "/share/vala", 27 | "*.la", 28 | "*.a" 29 | ], 30 | "modules" : [ 31 | { 32 | "name" : "libhandy", 33 | "buildsystem" : "meson", 34 | "config-opts" : [ 35 | "-Dexamples=false", 36 | "-Dglade_catalog=disabled", 37 | "-Dintrospection=disabled", 38 | "-Dtests=false", 39 | "-Dvapi=false" 40 | ], 41 | "sources" : [ 42 | { 43 | "type" : "git", 44 | "url" : "https://gitlab.gnome.org/GNOME/libhandy.git" 45 | } 46 | ] 47 | }, 48 | { 49 | "name" : "gnome-screenshot", 50 | "buildsystem" : "meson", 51 | "builddir" : true, 52 | "post-install": [ 53 | "echo 'hello'" 54 | ], 55 | "sources" : [ 56 | { 57 | "type" : "git", 58 | "url" : "https://gitlab.gnome.org/GNOME/gnome-screenshot.git" 59 | } 60 | ] 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /src/test/assets/org.valid.Manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "org.valid.Manifest", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "41", 5 | "sdk": "org.gnome.Sdk", 6 | "command": "app", 7 | "finish-args": [ 8 | "--share=ipc", 9 | "--socket=fallback-x11", 10 | "--socket=wayland", 11 | "--device=dri", 12 | "--require-version=1.12.5", 13 | "--metadata==X-DConf=migrate-path=/org/valid/Manifest/" 14 | ], 15 | "cleanup": [ 16 | "/include", 17 | "/lib/pkgconfig", 18 | "/man", 19 | "/share/doc", 20 | "/share/gtk-doc", 21 | "/share/man", 22 | "/share/pkgconfig", 23 | "*.la", 24 | "*.a" 25 | ], 26 | "modules": [ 27 | { 28 | "name": "app", 29 | "builddir": true, 30 | "buildsystem": "meson", 31 | "sources": [ 32 | { 33 | "type": "dir", 34 | "path": "." 35 | } 36 | ] 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/test/assets/org.valid.Manifest.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "org.valid.Manifest", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "41", 5 | "sdk": "org.gnome.Sdk", 6 | "command": "app", 7 | "finish-args": [ 8 | "--share=ipc", 9 | "--socket=fallback-x11", 10 | "--socket=wayland", 11 | "--device=dri", 12 | "--require-version=1.12.5", 13 | "--metadata==X-DConf=migrate-path=/org/valid/Manifest/" 14 | ], 15 | "cleanup": [ // A comment 16 | "/include", 17 | "/lib/pkgconfig", 18 | "/man", 19 | "/share/doc", 20 | "/share/gtk-doc", 21 | "/share/man", 22 | "/share/pkgconfig", 23 | "*.la", 24 | "*.a" 25 | ], 26 | "modules": [ 27 | { 28 | "name": "app", // Another comment 29 | "builddir": true, 30 | "buildsystem": "meson", 31 | "sources": [ 32 | { 33 | "type": "dir", 34 | "path": "." 35 | } 36 | ] 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/test/assets/org.valid.Manifest.yaml: -------------------------------------------------------------------------------- 1 | app-id: org.valid.Manifest 2 | runtime: org.gnome.Platform 3 | runtime-version: "41" 4 | sdk: org.gnome.Sdk 5 | command: app 6 | finish-args: 7 | - "--share=ipc" 8 | - "--socket=fallback-x11" 9 | - "--socket=wayland" 10 | - "--device=dri" 11 | - "--require-version=1.12.5" 12 | - "--metadata==X-DConf=migrate-path=/org/valid/Manifest/" 13 | cleanup: 14 | - "/include" 15 | - "/lib/pkgconfig" 16 | - "/man" 17 | - "/share/doc" 18 | - "/share/gtk-doc" 19 | - "/share/man" 20 | - "/share/pkgconfig" 21 | - "*.la" 22 | - "*.a" 23 | modules: 24 | - name: app 25 | builddir: true 26 | buildsystem: meson 27 | sources: 28 | - type: dir 29 | path: "." 30 | -------------------------------------------------------------------------------- /src/test/assets/org.valid.Manifest.yml: -------------------------------------------------------------------------------- 1 | app-id: org.valid.Manifest 2 | runtime: org.gnome.Platform 3 | runtime-version: "41" 4 | sdk: org.gnome.Sdk 5 | command: app 6 | finish-args: 7 | - "--share=ipc" 8 | - "--socket=fallback-x11" 9 | - "--socket=wayland" 10 | - "--device=dri" 11 | - "--require-version=1.12.5" 12 | - "--metadata==X-DConf=migrate-path=/org/valid/Manifest/" 13 | cleanup: 14 | - "/include" 15 | - "/lib/pkgconfig" 16 | - "/man" 17 | - "/share/doc" 18 | - "/share/gtk-doc" 19 | - "/share/man" 20 | - "/share/pkgconfig" 21 | - "*.la" 22 | - "*.a" 23 | modules: 24 | - name: app 25 | builddir: true 26 | buildsystem: meson 27 | sources: 28 | - type: dir 29 | path: "." 30 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { runTests } from 'vscode-test' 3 | 4 | async function main() { 5 | try { 6 | // The folder containing the Extension Manifest package.json 7 | // Passed to `--extensionDevelopmentPath` 8 | const extensionDevelopmentPath = path.resolve(__dirname, '../../') 9 | 10 | // The path to test runner 11 | // Passed to --extensionTestsPath 12 | const extensionTestsPath = path.resolve(__dirname, './suite/index') 13 | 14 | // Download VS Code, unzip it and run the integration test 15 | await runTests({ extensionDevelopmentPath, extensionTestsPath }) 16 | } catch (err) { 17 | process.exit(1) 18 | } 19 | } 20 | 21 | void main() 22 | -------------------------------------------------------------------------------- /src/test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | 3 | import * as assert from 'assert' 4 | import * as path from 'path' 5 | import { Uri } from 'vscode' 6 | import { resolve } from 'path' 7 | import { Manifest } from '../../manifest' 8 | import { ManifestMap } from '../../manifestMap' 9 | import { isValidDbusName, parseManifest } from '../../manifestUtils' 10 | import { versionCompare } from '../../flatpakUtils' 11 | import { exists, generatePathOverride } from '../../utils' 12 | import { Command } from '../../command' 13 | 14 | function intoUri(path: string): Uri { 15 | return Uri.file(resolve(__dirname, path)) 16 | } 17 | 18 | suite('command', () => { 19 | test('sandboxed', () => { 20 | const command = new Command('echo', ['Hello', 'world'], { forceSandbox: true }) 21 | assert.equal(command.program, 'flatpak-spawn') 22 | assert.deepStrictEqual(command.args, ['--host', '--watch-bus', '--env=TERM=xterm-256color', 'echo', 'Hello', 'world']) 23 | assert.equal(command.toString(), 'flatpak-spawn --host --watch-bus --env=TERM=xterm-256color echo Hello world') 24 | }) 25 | 26 | test('notSandboxed', () => { 27 | const command = new Command('echo', ['Hello', 'world']) 28 | assert.equal(command.program, 'echo') 29 | assert.deepStrictEqual(command.args, ['Hello', 'world']) 30 | assert.equal(command.toString(), 'echo Hello world') 31 | }) 32 | 33 | test('execSync', () => { 34 | const command = new Command('echo', ['Hello', 'world']) 35 | assert.equal(command.execSync().toString(), 'Hello world\n') 36 | }) 37 | }) 38 | 39 | suite('manifestMap', () => { 40 | function createTestManifest(uri: Uri): Manifest { 41 | return new Manifest(uri, { 42 | id: path.parse(uri.fsPath).name, 43 | modules: [], 44 | sdk: '', 45 | runtime: '', 46 | 'runtime-version': '', 47 | command: '', 48 | 'finish-args': [], 49 | }) 50 | } 51 | 52 | test('add, delete, get, getFirstItem, isEmpty, & size', () => { 53 | const map = new ManifestMap() 54 | assert(map.isEmpty()) 55 | assert.equal(map.getFirstItem(), undefined) 56 | 57 | const manifestAUri = Uri.file('/home/test/a.a.a.json') 58 | const manifestA = createTestManifest(manifestAUri) 59 | map.add(manifestA) 60 | assert.equal(map.size(), 1) 61 | assert.deepStrictEqual(map.get(manifestAUri), manifestA) 62 | assert.deepStrictEqual(map.getFirstItem(), manifestA) 63 | 64 | const manifestBUri = Uri.file('/home/test/b.b.b.json') 65 | const manifestB = createTestManifest(manifestBUri) 66 | map.add(manifestB) 67 | assert.equal(map.size(), 2) 68 | assert.deepStrictEqual(map.get(manifestBUri), manifestB) 69 | assert.deepStrictEqual(map.getFirstItem(), manifestA) 70 | 71 | assert(map.delete(manifestAUri)) 72 | assert.equal(map.size(), 1) 73 | assert.equal(map.get(manifestAUri), undefined) 74 | 75 | assert.deepStrictEqual(map.getFirstItem(), manifestB) 76 | 77 | assert(map.delete(manifestBUri)) 78 | assert.equal(map.size(), 0) 79 | assert.equal(map.get(manifestBUri), undefined) 80 | 81 | assert(map.isEmpty()) 82 | assert.equal(map.getFirstItem(), undefined) 83 | }) 84 | 85 | test('forEach', () => { 86 | const map = new ManifestMap() 87 | 88 | map.forEach(() => { 89 | throw new Error('forEach should not call the callback when empty.') 90 | }) 91 | 92 | const manifestAUri = Uri.file('/home/test/a.a.a.json') 93 | const manifestA = createTestManifest(manifestAUri) 94 | map.add(manifestA) 95 | 96 | const manifestBUri = Uri.file('/home/test/b.b.b.json') 97 | const manifestB = createTestManifest(manifestBUri) 98 | map.add(manifestB) 99 | 100 | let nIter = 0 101 | map.forEach((manifest) => { 102 | nIter++ 103 | assert.notEqual(manifest, undefined) 104 | }) 105 | assert.equal(nIter, 2) 106 | }) 107 | 108 | test('update', () => { 109 | const first_map = new ManifestMap() 110 | assert(first_map.isEmpty()) 111 | 112 | const second_map = new ManifestMap() 113 | assert(second_map.isEmpty()) 114 | 115 | const manifestAUri = Uri.file('/home/test/a.a.a.json') 116 | const manifestA = createTestManifest(manifestAUri) 117 | 118 | const manifestBUri = Uri.file('/home/test/b.b.b.json') 119 | const manifestB = createTestManifest(manifestBUri) 120 | 121 | first_map.add(manifestA) 122 | assert.equal(first_map.size(), 1) 123 | assert.deepStrictEqual(first_map.get(manifestAUri), manifestA) 124 | 125 | second_map.add(manifestB) 126 | assert.equal(second_map.size(), 1) 127 | assert.deepStrictEqual(second_map.get(manifestBUri), manifestB) 128 | 129 | first_map.update(second_map) 130 | assert.equal(first_map.size(), 1) 131 | assert.deepStrictEqual(first_map.get(manifestBUri), manifestB) 132 | 133 | second_map.delete(manifestBUri) 134 | assert(second_map.isEmpty()) 135 | 136 | first_map.update(second_map) 137 | assert(first_map.isEmpty()) 138 | 139 | second_map.add(manifestA) 140 | second_map.add(manifestB) 141 | assert.equal(second_map.size(), 2) 142 | 143 | first_map.update(second_map) 144 | assert.equal(first_map.size(), 2) 145 | }) 146 | }) 147 | 148 | suite('manifestUtils', () => { 149 | test('parseManifest', async () => { 150 | 151 | async function assertValidManifest(path: string): Promise { 152 | const manifest = await parseManifest(intoUri(path)) 153 | assert.notEqual(manifest, null) 154 | assert.equal(manifest?.manifest['app-id'], 'org.valid.Manifest') 155 | assert.equal(manifest?.manifest.runtime, 'org.gnome.Platform') 156 | assert.equal(manifest?.manifest['runtime-version'], '41') 157 | assert.equal(manifest?.manifest.sdk, 'org.gnome.Sdk') 158 | assert.equal(manifest?.manifest.command, 'app') 159 | assert.equal(manifest?.manifest.modules[0].name, 'app') 160 | assert.equal(manifest?.manifest.modules[0].buildsystem, 'meson') 161 | assert.equal(manifest?.requiredVersion, '1.12.5') 162 | assert(!manifest?.finishArgs().map((arg) => arg.split('=')[0]).includes('--require-version')) 163 | assert(!manifest?.finishArgs().map((arg) => arg.split('=')[0]).includes('--metadata')) 164 | } 165 | 166 | async function assertInvalidManifest(path: string): Promise { 167 | const manifest = await parseManifest(intoUri(path)) 168 | assert.equal(manifest, null) 169 | } 170 | 171 | await assertValidManifest('../assets/org.valid.Manifest.json') 172 | await assertValidManifest('../assets/org.valid.Manifest.jsonc') 173 | await assertValidManifest('../assets/org.valid.Manifest.yaml') 174 | await assertValidManifest('../assets/org.valid.Manifest.yml') 175 | 176 | await assertInvalidManifest('../assets/.has.invalid.AppId.yml') 177 | await assertInvalidManifest('../assets/has.missing.Modules.json') 178 | await assertInvalidManifest('../assets/has.missing.AppId.json') 179 | }) 180 | 181 | test('isValidDbusName', () => { 182 | assert( 183 | isValidDbusName('_org.SomeApp'), 184 | ) 185 | assert( 186 | isValidDbusName('com.org.SomeApp'), 187 | ) 188 | assert( 189 | isValidDbusName('com.org_._SomeApp'), 190 | ) 191 | assert( 192 | isValidDbusName('com.org-._SomeApp'), 193 | ) 194 | assert( 195 | isValidDbusName('com.org._1SomeApp'), 196 | ) 197 | assert( 198 | isValidDbusName('com.org._1_SomeApp'), 199 | ) 200 | assert( 201 | isValidDbusName('VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.a111111111111'), 202 | ) 203 | 204 | assert( 205 | !isValidDbusName('package'), 206 | 'DBus name must contain at least two elements' 207 | ) 208 | assert( 209 | !isValidDbusName('NoDot'), 210 | 'DBus name must contain at least two elements' 211 | ) 212 | assert( 213 | !isValidDbusName('No-dot'), 214 | 'DBus name must contain at least two elements' 215 | ) 216 | assert( 217 | !isValidDbusName('No_dot'), 218 | 'DBus name must contain at least two elements' 219 | ) 220 | assert( 221 | !isValidDbusName('Has.Two..Consecutive.Dots'), 222 | 'DBus name elements must have at least one valid character' 223 | ) 224 | assert( 225 | !isValidDbusName('HasThree...Consecutive.Dots'), 226 | 'DBus name elements must have at least one valid character' 227 | ) 228 | assert( 229 | !isValidDbusName('.StartsWith.A.Period'), 230 | 'DBus name must not start with a period' 231 | ) 232 | assert( 233 | !isValidDbusName('.'), 234 | 'DBus name must not start with a period' 235 | ) 236 | assert( 237 | !isValidDbusName('Ends.With.A.Period.'), 238 | 'DBus name must not end with a period' 239 | ) 240 | assert( 241 | !isValidDbusName('0P.Starts.With.A.Digit'), 242 | 'DBus name must not start with a digit' 243 | ) 244 | assert( 245 | !isValidDbusName('com.org.1SomeApp'), 246 | 'DBus name element must not start with a digit' 247 | ) 248 | assert( 249 | !isValidDbusName('Element.Starts.With.A.1Digit'), 250 | 'DBus name element must not start with a digit' 251 | ) 252 | assert( 253 | !isValidDbusName('VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.VeryLongApplicationId.a1111111111112'), 254 | 'DBus name must have less than or equal 255 characters' 255 | ) 256 | assert( 257 | !isValidDbusName(''), 258 | 'DBus name must not be empty' 259 | ) 260 | assert( 261 | !isValidDbusName('contains.;nvalid.characters'), 262 | 'The characters must only contain a-z, A-Z, periods, or underscores' 263 | ) 264 | assert( 265 | !isValidDbusName('con\nins.invalid.characters'), 266 | 'The characters must only contain a-z, A-Z, periods, or underscores' 267 | ) 268 | assert( 269 | !isValidDbusName('con/ains.invalid.characters'), 270 | 'The characters must only contain a-z, A-Z, periods, or underscores' 271 | ) 272 | assert( 273 | !isValidDbusName('conta|ns.invalid.characters'), 274 | 'The characters must only contain a-z, A-Z, periods, or underscores' 275 | ) 276 | assert( 277 | !isValidDbusName('contæins.inva_å_lid.characters'), 278 | 'The characters must only contain a-z, A-Z, periods, or underscores' 279 | ) 280 | }) 281 | }) 282 | 283 | suite('flatpakUtils', () => { 284 | test('versionCompare', () => { 285 | assert(versionCompare('1.12.5', '1.12.0')) 286 | assert(versionCompare('1.8.5', '1.2.0')) 287 | assert(versionCompare('0.9.2', '0.9.2')) 288 | 289 | assert(!versionCompare('1.12.5', '1.19.0')) 290 | assert(!versionCompare('1.0.0', '1.2.0')) 291 | assert(!versionCompare('0.9.2', '1.2.0')) 292 | }) 293 | }) 294 | 295 | suite('manifest', () => { 296 | test('x-run-args', async () => { 297 | const manifest = await parseManifest(intoUri('../assets/org.gnome.Screenshot.json')) 298 | assert.deepEqual(manifest?.manifest['x-run-args'], ['--interactive']) 299 | 300 | const runCommand = await manifest?.run() 301 | assert(runCommand?.toString().endsWith('gnome-screenshot --interactive')) 302 | }) 303 | 304 | test('post-install', async () => { 305 | const manifest = await parseManifest(intoUri('../assets/org.gnome.Screenshot.json')) 306 | assert.deepEqual(manifest?.module()['post-install'], ['echo \'hello\'']) 307 | 308 | const postInstallCommand = manifest?.build(false).slice(-1)[0] 309 | assert(postInstallCommand?.toString().endsWith('echo \'hello\'')) 310 | 311 | const postInstallCommand2 = manifest?.build(true).slice(-1)[0] 312 | assert(postInstallCommand2?.toString().endsWith('echo \'hello\'')) 313 | }) 314 | }) 315 | 316 | suite('utils', () => { 317 | test('generatePathOverride', () => { 318 | assert.equal(generatePathOverride('/a/a:/b/b', [], [], []), '/a/a:/b/b') 319 | assert.equal(generatePathOverride('/a/a:/b/b', [], ['', ''], ['']), '/a/a:/b/b') 320 | assert.equal(generatePathOverride('/a/a:/b/b', [], ['/c/c', ''], ['', '/d/d']), '/c/c:/a/a:/b/b:/d/d') 321 | assert.equal(generatePathOverride('', [], [''], ['']), '') 322 | assert.equal(generatePathOverride('', [], ['/b/b'], ['/c/c']), '/b/b:/c/c') 323 | assert.equal(generatePathOverride('', [], ['/b/b', '/d/d'], ['/c/c', '/e/e']), '/b/b:/d/d:/c/c:/e/e') 324 | assert.equal(generatePathOverride('/a/a', [], [], ['/c/c']), '/a/a:/c/c') 325 | assert.equal(generatePathOverride('/a/a', [], ['/b/b'], []), '/b/b:/a/a') 326 | assert.equal(generatePathOverride('/a/a', [], ['/b/b'], ['/c/c']), '/b/b:/a/a:/c/c') 327 | assert.equal(generatePathOverride('/a/a', [], ['/b/b', '/d/d'], ['/c/c', '/e/e']), '/b/b:/d/d:/a/a:/c/c:/e/e') 328 | assert.equal(generatePathOverride('/a/a:/f/f', [], ['/b/b', '/d/d'], ['/c/c', '/e/e']), '/b/b:/d/d:/a/a:/f/f:/c/c:/e/e') 329 | }) 330 | 331 | test('exists', async () => { 332 | assert(await exists(intoUri('../assets/org.valid.Manifest.json').fsPath)) 333 | assert(!await exists(intoUri('../assets/sOmE.nOnExistenT.FilE.abc').fsPath)) 334 | }) 335 | }) 336 | -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as Mocha from 'mocha' 3 | import * as glob from 'glob' 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | color: true, 10 | }) 11 | 12 | const testsRoot = path.resolve(__dirname, '..') 13 | 14 | return new Promise((c, e) => { 15 | try { 16 | const files = glob.globSync('**/**.test.js', { cwd: testsRoot }) 17 | 18 | // Add files to the test suite 19 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))) 20 | 21 | // Run the mocha test 22 | mocha.run((failures) => { 23 | if (failures > 0) { 24 | e(new Error(`${failures} tests failed.`)) 25 | } else { 26 | c() 27 | } 28 | }) 29 | } catch (err) { 30 | e(err) 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/test/suite/outputTerminal.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import * as vscode from 'vscode' 3 | import { OutputTerminal } from '../../outputTerminal' 4 | import { Command } from '../../command' 5 | 6 | suite('dimensions', () => { 7 | test('synced IPty and OutputTerminal dimensions', async () => { 8 | const outputTerminal = new OutputTerminal() 9 | await outputTerminal.show() 10 | 11 | const command = new Command('echo', ['Hello, world!']) 12 | const iPty = await command.spawn(outputTerminal, (new vscode.CancellationTokenSource).token) 13 | assert.equal(outputTerminal.dimensions?.columns, iPty.cols) 14 | assert.equal(outputTerminal.dimensions?.rows, iPty.rows) 15 | }) 16 | 17 | test('initially unsynced IPty and OutputTerminal dimensions', async () => { 18 | const outputTerminal = new OutputTerminal() 19 | 20 | const command = new Command('echo', ['Hello, world!']) 21 | const iPty1 = await command.spawn(outputTerminal, (new vscode.CancellationTokenSource).token) 22 | // Since outputTerminal is not shown, it should have undefined dimensions. 23 | // Thus, it should not affect the dimensions of iPty1. 24 | assert.equal(outputTerminal.dimensions, undefined) 25 | 26 | await outputTerminal.show() 27 | 28 | const iPty2 = await command.spawn(outputTerminal, (new vscode.CancellationTokenSource).token) 29 | assert.equal(outputTerminal.dimensions?.columns, iPty2.cols) 30 | assert.equal(outputTerminal.dimensions?.rows, iPty2.rows) 31 | 32 | // Since the outputTerminal is already shown. It should 33 | // affect the dimensions of iPty2, and differ from iPty1. 34 | assert.notEqual(iPty1.cols, iPty2.cols) 35 | assert.notEqual(iPty1.rows, iPty2.rows) 36 | }) 37 | 38 | test('reset dimensions on close', async () => { 39 | const outputTerminal = new OutputTerminal() 40 | await outputTerminal.show() 41 | 42 | const command = new Command('echo', ['Hello, world!']) 43 | const iPty = await command.spawn(outputTerminal, (new vscode.CancellationTokenSource).token) 44 | assert.equal(outputTerminal.dimensions?.columns, iPty.cols) 45 | assert.equal(outputTerminal.dimensions?.rows, iPty.rows) 46 | 47 | outputTerminal.dispose() 48 | assert.equal(outputTerminal.dimensions, undefined) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as dbus from 'dbus-next' 2 | import { promises as fs, constants as fsc, PathLike } from 'fs' 3 | import * as vscode from 'vscode' 4 | import * as path from 'path' 5 | import { homedir } from 'os' 6 | import { env } from 'process' 7 | import { IS_SANDBOXED } from './extension' 8 | import { Command } from './command' 9 | import { Lazy } from './lazy' 10 | 11 | const HOME_DIR = new Lazy(() => { 12 | return homedir() 13 | }) 14 | const SYSTEM_FONTS_DIR = '/usr/share/fonts' 15 | const SYSTEM_LOCAL_FONT_DIR = '/usr/share/local/fonts' 16 | const SYSTEM_FONT_CACHE_DIRS = [ 17 | '/usr/lib/fontconfig/cache', 18 | '/var/cache/fontconfig', 19 | ] 20 | const USER_CACHE_DIR = new Lazy(() => { 21 | if (IS_SANDBOXED.get()) { 22 | return path.join(HOME_DIR.get(), '.cache') 23 | } else { 24 | return env.XDG_CACHE_HOME || path.join(HOME_DIR.get(), '.cache') 25 | } 26 | }) 27 | const USER_DATA_DIR = new Lazy(() => { 28 | if (IS_SANDBOXED.get()) { 29 | return path.join(HOME_DIR.get(), '.local/share') 30 | } else { 31 | return env.XDG_DATA_HOME || path.join(HOME_DIR.get(), '.local/share') 32 | } 33 | }) 34 | const USER_FONTS = new Lazy(() => { 35 | return [ 36 | path.join(USER_DATA_DIR.get(), 'fonts'), 37 | path.join(HOME_DIR.get(), '.fonts') 38 | ] 39 | }) 40 | const USER_FONTS_CACHE_DIR = new Lazy(() => { 41 | return path.join(USER_CACHE_DIR.get(), 'fontconfig') 42 | }) 43 | 44 | /** 45 | * Make sures the documents portal is running 46 | */ 47 | export async function ensureDocumentsPortal(): Promise { 48 | try { 49 | const bus = dbus.sessionBus() 50 | const obj = await bus.getProxyObject('org.freedesktop.portal.Documents', '/org/freedesktop/portal/documents') 51 | const portal = obj.getInterface('org.freedesktop.portal.Documents') 52 | await portal.GetMountPoint() 53 | } catch (err) { 54 | console.warn(`Failed to ensure documents portal: ${err as string}`) 55 | } 56 | } 57 | 58 | export async function exists(path: PathLike): Promise { 59 | try { 60 | await fs.access(path, fsc.R_OK) 61 | return true 62 | } catch { 63 | return false 64 | } 65 | } 66 | 67 | /** 68 | * Similar to exists but verifies that the path exists on the host. 69 | */ 70 | export async function existsOnHost(p: PathLike): Promise { 71 | if (!IS_SANDBOXED.get()) { 72 | return await exists(p) 73 | } else { 74 | const local = path.join('/var/run/host', p as string) 75 | if (await exists(local)) { 76 | return true 77 | } else { 78 | const { exitCode } = new Command('ls', ['-d', p as string]).exec() 79 | if (exitCode === 0) { 80 | return true 81 | } 82 | } 83 | } 84 | return false 85 | } 86 | 87 | export function getHostEnv(): Map { 88 | const forwardedEnvKeys: string[] = [ 89 | 'COLORTERM', 90 | 'DESKTOP_SESSION', 91 | 'LANG', 92 | 'WAYLAND_DISPLAY', 93 | 'XDG_CURRENT_DESKTOP', 94 | 'XDG_SEAT', 95 | 'XDG_SESSION_DESKTOP', 96 | 'XDG_SESSION_ID', 97 | 'XDG_SESSION_TYPE', 98 | 'XDG_VTNR', 99 | 'AT_SPI_BUS_ADDRESS', 100 | ] 101 | 102 | const envVars = new Map() 103 | 104 | for (const [key, value] of Object.entries(process.env)) { 105 | if (value !== undefined && forwardedEnvKeys.includes(key)) { 106 | envVars.set(key, value) 107 | } 108 | } 109 | return envVars 110 | } 111 | 112 | export function generatePathOverride(oldValue: string | undefined, defaultValue: string[], prependValues: (PathLike | undefined)[], appendValues: (PathLike | undefined)[]): string { 113 | return [...prependValues, oldValue, ...defaultValue, ...appendValues] 114 | .filter((path) => !!path) // Filters out empty strings and undefined 115 | .join(':') 116 | } 117 | 118 | export async function appendWatcherExclude(paths: PathLike[]) { 119 | const config = vscode.workspace.getConfiguration('files') 120 | const value: Record = config.get('watcherExclude') || {} 121 | 122 | for (const path of paths) { 123 | value[path.toString()] = true 124 | } 125 | 126 | await config.update('watcherExclude', value) 127 | } 128 | 129 | /** 130 | * Attempts to show the data directory of the app, typically in a file explorer. 131 | * @param appId The app id of the app 132 | */ 133 | export function showDataDirectory(appId: string) { 134 | const dataDirectory = path.join(HOME_DIR.get(), '.var/app/', appId) 135 | console.log(`Showing data directory at: ${dataDirectory}`) 136 | 137 | if (IS_SANDBOXED.get()) { 138 | // Spawn in host since a Flatpak-ed app cannot access other Flatpak apps 139 | // data directory and would just fail silently if VSCode API's openExternal is used. 140 | new Command('xdg-open', [dataDirectory]).exec() 141 | } else { 142 | void vscode.env.openExternal(vscode.Uri.file(dataDirectory)) 143 | } 144 | } 145 | 146 | /** 147 | * Get bind mounts for the host fonts & their cache. 148 | */ 149 | export async function getFontsArgs(): Promise { 150 | const args: string[] = [] 151 | const mappedFontFile = path.join(USER_CACHE_DIR.get(), 'font-dirs.xml') 152 | let fontDirContent = '\n' 153 | fontDirContent += '\n' 154 | fontDirContent += '\n' 155 | if (await existsOnHost(SYSTEM_FONTS_DIR)) { 156 | args.push(`--bind-mount=/run/host/fonts=${SYSTEM_FONTS_DIR}`) 157 | fontDirContent += `\t/run/host/fonts\n` 158 | } 159 | if (await exists(SYSTEM_LOCAL_FONT_DIR)) { 160 | args.push(`--bind-mount=/run/host/local-fonts=${SYSTEM_LOCAL_FONT_DIR}`) 161 | fontDirContent += `\t/run/host/local-fonts\n` 162 | } 163 | for (const cache of SYSTEM_FONT_CACHE_DIRS) { 164 | if (await existsOnHost(cache)) { 165 | args.push(`--bind-mount=/run/host/fonts-cache=${cache}`) 166 | break 167 | } 168 | } 169 | for (const dir of USER_FONTS.get()) { 170 | if (await exists(dir)) { 171 | args.push(`--filesystem=${dir}:ro`) 172 | fontDirContent += `\t/run/host/user-fonts\n` 173 | } 174 | } 175 | if (await exists(USER_FONTS_CACHE_DIR.get())) { 176 | args.push(`--filesystem=${USER_FONTS_CACHE_DIR.get()}:ro`) 177 | args.push(`--bind-mount=/run/host/user-fonts-cache=${USER_FONTS_CACHE_DIR.get()}`) 178 | } 179 | fontDirContent += '\n' 180 | args.push(`--bind-mount=/run/host/font-dirs.xml=${mappedFontFile}`) 181 | await fs.writeFile(mappedFontFile, fontDirContent) 182 | return args 183 | } 184 | 185 | export async function getA11yBusArgs(): Promise { 186 | try { 187 | let unixPath: string 188 | let suffix: string | null = null 189 | const chunks: Buffer[] = [] 190 | let chunk: ArrayBuffer 191 | const { stdout } = new Command('gdbus', [ 192 | 'call', 193 | '--session', 194 | '--dest=org.a11y.Bus', 195 | '--object-path=/org/a11y/bus', 196 | '--method=org.a11y.Bus.GetAddress' 197 | ]).exec() 198 | 199 | if (stdout === null) { 200 | console.error('Failed to retrieve accessibility bus') 201 | return [] 202 | } 203 | for await (chunk of stdout) { 204 | chunks.push(Buffer.from(chunk)) 205 | } 206 | const address = Buffer.concat(chunks).toString('utf-8').trim().replace('(\'', '').replace('\',)', '') 207 | console.log(`Accessibility bus retrieved: ${address}`) 208 | 209 | const start = address.indexOf('unix:path=') 210 | if (start === -1) { 211 | return [] 212 | } 213 | const end = address.indexOf(',', start) 214 | if (end === -1) { 215 | unixPath = address.substring('unix:path='.length) 216 | } else { 217 | unixPath = address.substring('unix:path='.length, end) 218 | suffix = address.substring(end + 1) 219 | } 220 | const args = [`--bind-mount=/run/flatpak/at-spi-bus=${unixPath}`] 221 | if (suffix !== null) { 222 | args.push(`--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus${suffix}`) 223 | } else { 224 | args.push('--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus') 225 | } 226 | return args 227 | } catch { 228 | console.error('Failed to retrieve accessibility bus') 229 | return [] 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/workspaceState.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | type WorkspaceStateKey = 4 | // The current manifest URI that directs to active manifest. 5 | // This is used to retrieve the last active manifest between startup. 6 | 'ActiveManifestUri' | 7 | // Whether dependencies of the application are up to date 8 | 'DependenciesUpdated' | 9 | // Whether dependencies of the application are built 10 | 'DependenciesBuilt' | 11 | // Whether dependencies of the application are built and ready to run 12 | 'ApplicationBuilt' 13 | 14 | /** 15 | * Persistent state within the workspace across startups. 16 | */ 17 | export class WorkspaceState { 18 | private readonly extCtx: vscode.ExtensionContext 19 | 20 | constructor(extCtx: vscode.ExtensionContext) { 21 | this.extCtx = extCtx 22 | } 23 | 24 | async loadContexts(): Promise { 25 | // Contexts has to be set again between startups 26 | await this.setContext('flatpakHasActiveManifest', this.getActiveManifestUri() !== undefined) 27 | await this.setContext('flatpakDependenciesBuilt', this.getDependenciesBuilt()) 28 | await this.setContext('flatpakApplicationBuilt', this.getApplicationBuilt()) 29 | } 30 | 31 | async setActiveManifestUri(value: vscode.Uri | undefined): Promise { 32 | await this.setContext('flatpakHasActiveManifest', value !== undefined) 33 | await this.update('ActiveManifestUri', value) 34 | } 35 | 36 | getActiveManifestUri(): vscode.Uri | undefined { 37 | return this.get('ActiveManifestUri') 38 | } 39 | 40 | async setDependenciesUpdated(value: boolean): Promise { 41 | await this.update('DependenciesUpdated', value) 42 | } 43 | 44 | getDependenciesUpdated(): boolean { 45 | return this.get('DependenciesUpdated') || false 46 | } 47 | 48 | async setDependenciesBuilt(value: boolean): Promise { 49 | await this.setContext('flatpakDependenciesBuilt', value) 50 | await this.update('DependenciesBuilt', value) 51 | } 52 | 53 | getDependenciesBuilt(): boolean { 54 | return this.get('DependenciesBuilt') || false 55 | } 56 | 57 | async setApplicationBuilt(value: boolean): Promise { 58 | await this.setContext('flatpakApplicationBuilt', value) 59 | await this.update('ApplicationBuilt', value) 60 | } 61 | 62 | getApplicationBuilt(): boolean { 63 | return this.get('ApplicationBuilt') || false 64 | } 65 | 66 | private async update(key: WorkspaceStateKey, value: vscode.Uri | boolean | undefined) { 67 | return this.extCtx.workspaceState.update(key, value) 68 | } 69 | 70 | private get(key: WorkspaceStateKey): T | undefined { 71 | return this.extCtx.workspaceState.get(key) 72 | } 73 | 74 | private async setContext(contextName: string, value: boolean): Promise { 75 | await vscode.commands.executeCommand('setContext', contextName, value) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /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, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noUnusedParameters": true, 15 | "noImplicitOverride": true 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | ".vscode-test" 20 | ] 21 | } 22 | --------------------------------------------------------------------------------