Compare commits

...

8 Commits

Author SHA1 Message Date
Pragadesh-45
8d77d05b34 Revert "Feat/ Add Global Shortcuts for Zoom, Minimize, and Close on Windows (fixes: #4108) (#4110)" (#5504)
This reverts commit ce0fc08500.
2025-09-04 17:57:18 +05:30
lohit
518b1ed441 include oauth2 additional parameters in bruno collection exports (#5422) 2025-08-28 20:10:27 +05:30
Sanjai Kumar
c1aa682c03 fix: environment persistence and UI (#5404) 2025-08-28 20:10:19 +05:30
Martin Braconi
01275acc89 Update: readme.md installation instructions via Apt (#5411) 2025-08-28 20:10:11 +05:30
naman-bruno
c8f223a000 fix: large response 2025-08-28 20:09:52 +05:30
lohit
4202b48edd chore: eslint updates and fixes (#5402) 2025-08-28 20:09:25 +05:30
lohit
69891c0bc7 electron builder updates (#5425) 2025-08-28 20:09:16 +05:30
Bijin A B
76729519c6 Update contributing.md (#5407) 2025-08-28 20:09:08 +05:30
20 changed files with 1174 additions and 2037 deletions

View File

@@ -69,6 +69,7 @@ npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:bruno-filestore
# bundle js sandbox libraries
npm run sandbox:bundle-libraries --workspace=packages/bruno-js

View File

@@ -5,7 +5,7 @@ const globals = require("globals");
module.exports = defineConfig([
{
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
ignores: ["**/*.config.js"],
ignores: ["**/*.config.js", "**/public/**/*"],
languageOptions: {
globals: {
...globals.browser,
@@ -13,7 +13,8 @@ module.exports = defineConfig([
global: false,
require: false,
Buffer: false,
process: false
process: false,
ipcRenderer: false
},
parserOptions: {
ecmaFeatures: {
@@ -39,8 +40,60 @@ module.exports = defineConfig([
},
},
{
files: ["packages/bruno-electron/**/*.{js}"],
files: ["packages/bruno-cli/**/*.js"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parserOptions: {
ecmaVersion: "latest"
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-common/**/*.ts"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: require("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./packages/bruno-common/tsconfig.json",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-converters/**/*.js"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-electron/**/*.js"],
ignores: ["**/*.config.js", "**/web/**/*"],
languageOptions: {
globals: {
...globals.node,
@@ -50,5 +103,98 @@ module.exports = defineConfig([
rules: {
"no-undef": "error",
},
}
},
{
files: ["packages/bruno-filestore/**/*.ts"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: require("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./packages/bruno-filestore/tsconfig.json",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-js/**/*.js"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
window: false,
self: false,
HTMLElement: false,
typeDetectGlobalObject: false
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-lang/**/*.js"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-requests/**/*.ts"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: require("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./packages/bruno-requests/tsconfig.json",
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-requests/**/*.js"],
ignores: ["**/*.config.js", "**/dist/**/*"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
rules: {
"no-undef": "error",
},
},
]);

2795
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"fs-extra": "^11.1.1",
@@ -76,4 +77,4 @@
}
}
}
}
}

View File

@@ -80,10 +80,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
const [filter, setFilter] = useState(null);
const [showLargeResponse, setShowLargeResponse] = useState(false);
const responseEncoding = getEncoding(headers);
const formattedData = useMemo(
() => formatResponse(data, dataBuffer, responseEncoding, mode, filter),
[data, dataBuffer, responseEncoding, mode, filter]
);
const { displayedTheme } = useTheme();
const responseSize = useMemo(() => {
@@ -105,6 +101,16 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
const isLargeResponse = responseSize > 10 * 1024 * 1024; // 10 MB
const formattedData = useMemo(
() => {
if (isLargeResponse && !showLargeResponse) {
return '';
}
return formatResponse(data, dataBuffer, responseEncoding, mode, filter);
},
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
);
const debouncedResultFilterOnChange = debounce((e) => {
setFilter(e.target.value);
}, 250);

View File

@@ -5,10 +5,10 @@ const KeyMapping = {
newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
closeBruno: {
mac: 'command+Q',
windows: 'ctrl+shift+q',
name: 'Close Bruno'
minimizeWindow: {
mac: 'command+Shift+Q',
windows: 'control+Shift+Q',
name: 'Minimize Window'
},
switchToPreviousTab: {
mac: 'command+pageup',

View File

@@ -41,7 +41,8 @@ import {
initRunRequestEvent,
updateRunnerConfiguration as _updateRunnerConfiguration,
updateActiveConnections,
saveRequest as _saveRequest
saveRequest as _saveRequest,
saveEnvironment as _saveEnvironment
} from './index';
import { each } from 'lodash';
@@ -59,6 +60,7 @@ import {
calculateDraggedItemNewPathname
} from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
import { buildPersistedEnvVariables } from 'utils/environments';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { updateSettingsSelectedTab } from './index';
@@ -1167,8 +1169,16 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
// strip "ephemeral" metadata
const variablesToCopy = (baseEnv.variables || [])
.filter((v) => !v.ephemeral)
.map(({ ephemeral, ...rest }) => {
return rest;
});
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variablesToCopy)
.then(
dispatch(
updateLastAction({
@@ -1249,12 +1259,27 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
return reject(new Error('Environment not found'));
}
environment.variables = variables;
/*
Modal Save writes what the user sees:
- Non-ephemeral vars are saved as-is (without metadata)
- Ephemeral vars:
- if persistedValue exists, save that (explicit persisted case)
- otherwise save the current UI value (treat as user-authored)
*/
const persisted = buildPersistedEnvVariables(variables, { mode: 'save' });
environment.variables = persisted;
const { ipcRenderer } = window;
const envForValidation = cloneDeep(environment);
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, envForValidation))
.then(() => {
// Immediately sync Redux to the saved (persisted) set so old ephemerals
// arent around when the watcher event arrives.
dispatch(_saveEnvironment({ variables: persisted, environmentUid, collectionUid }));
})
.then(resolve)
.catch(reject);
});
@@ -1311,12 +1336,15 @@ export const mergeAndPersistEnvironment =
}
});
environment.variables = merged;
// Save only non-ephemeral vars, or ephemerals explicitly persisted this run
const persistedNames = new Set(Object.keys(persistentEnvVariables));
const environmentToSave = cloneDeep(environment);
environmentToSave.variables = buildPersistedEnvVariables(merged, { mode: 'merge', persistedNames });
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
.validate(environmentToSave)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environmentToSave))
.then(resolve)
.catch(reject);
});

View File

@@ -284,7 +284,20 @@ export const collectionsSlice = createSlice({
const variable = find(activeEnvironment.variables, (v) => v.name === key);
if (variable) {
variable.value = value;
// For updates coming from scripts, treat them as ephemeral overlays.
if (variable.value !== value) {
/*
Overlay (persist: false): keep new value in Redux for UI and mark ephemeral
so it isn't written to disk. persistedValue stores the previous on-disk value;
save/persist uses that base unless the key is explicitly persisted.
*/
const previousValue = variable.value;
variable.value = value;
variable.ephemeral = true;
if (variable.persistedValue === undefined) {
variable.persistedValue = previousValue;
}
}
} else {
// __name__ is a private variable used to store the name of the environment
// this is not a user defined variable and hence should not be updated
@@ -295,7 +308,8 @@ export const collectionsSlice = createSlice({
secret: false,
enabled: true,
type: 'text',
uid: uuid()
uid: uuid(),
ephemeral: true,
});
}
}
@@ -2275,7 +2289,21 @@ export const collectionsSlice = createSlice({
const existingEnv = collection.environments.find((e) => e.uid === environment.uid);
if (existingEnv) {
const prevEphemerals = (existingEnv.variables || []).filter((v) => v.ephemeral);
existingEnv.variables = environment.variables;
/*
Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves.
*/
prevEphemerals.forEach((ev) => {
const target = existingEnv.variables?.find((v) => v.name === ev.name);
if (target) {
if (target.value !== ev.value) {
if (target.persistedValue === undefined) target.persistedValue = target.value;
target.value = ev.value;
}
target.ephemeral = true;
}
});
} else {
collection.environments.push(environment);
collection.environments.sort((a, b) => a.name.localeCompare(b.name));

View File

@@ -1,5 +1,6 @@
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
import { buildPersistedEnvVariables } from 'utils/environments';
import { sortByNameThenSequence } from 'utils/common/index';
import path from 'utils/common/path';
import { isRequestTagsIncluded } from '@usebruno/common';
@@ -336,6 +337,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {}),
};
break;
case 'authorization_code':
@@ -356,6 +358,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {}),
};
break;
case 'implicit':
@@ -371,6 +374,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {}),
};
break;
case 'client_credentials':
@@ -388,6 +392,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {}),
};
break;
}
@@ -502,7 +507,11 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
collectionToSave.version = '1';
collectionToSave.items = [];
collectionToSave.activeEnvironmentUid = collection.activeEnvironmentUid;
collectionToSave.environments = collection.environments || [];
// Save environments without runtime metadata (ephemeral/persistedValue)
collectionToSave.environments = (collection.environments || []).map((env) => ({
...env,
variables: buildPersistedEnvVariables(env?.variables, { mode: 'save' })
}));
collectionToSave.root = {
request: {}

View File

@@ -0,0 +1,31 @@
const isPersistableEnvVarForMerge = (persistedNames) => (v) => {
return !v?.ephemeral || v?.persistedValue !== undefined || (v?.name && persistedNames.has(v.name));
};
const toPersistedEnvVarForMerge = (persistedNames) => (v) => {
const { ephemeral, persistedValue, ...rest } = v || {};
if (v?.ephemeral && persistedValue !== undefined && !(v?.name && persistedNames.has(v.name))) {
return { ...rest, value: persistedValue };
}
return rest;
};
const toPersistedEnvVarForSave = (v) => {
const { ephemeral, persistedValue, ...rest } = v || {};
return v?.ephemeral ? (persistedValue !== undefined ? { ...rest, value: persistedValue } : rest) : rest;
};
/*
High-level builder for persisted variables
- mode 'save': write what the user sees
- mode 'merge': write only allowed vars (non-ephemeral, ephemerals with persistedValue, or explicitly persisted this run)
*/
export const buildPersistedEnvVariables = (variables, { mode, persistedNames } = {}) => {
const src = Array.isArray(variables) ? variables : [];
if (mode === 'merge') {
const names = persistedNames instanceof Set ? persistedNames : new Set();
return src.filter(isPersistableEnvVarForMerge(names)).map(toPersistedEnvVarForMerge(names));
}
// default to save mode
return src.map(toPersistedEnvVarForSave);
};

View File

@@ -9,11 +9,11 @@ async function resolveAwsV4Credentials(request) {
const awsv4 = request.awsv4config;
if (isStrPresent(awsv4.profileName)) {
try {
credentialsProvider = fromIni({
const credentialsProvider = fromIni({
profile: awsv4.profileName,
ignoreCache: true
});
credentials = await credentialsProvider();
const credentials = await credentialsProvider();
awsv4.accessKeyId = credentials.accessKeyId;
awsv4.secretAccessKey = credentials.secretAccessKey;
awsv4.sessionToken = credentials.sessionToken;

View File

@@ -3,7 +3,7 @@ require('dotenv').config({ path: process.env.DOTENV_PATH });
const config = {
appId: 'com.usebruno.app',
productName: 'Bruno',
electronVersion: '33.2.1',
electronVersion: '37.2.6',
directories: {
buildResources: 'resources',
output: 'out'

View File

@@ -76,7 +76,7 @@
},
"devDependencies": {
"electron": "~37.2.6",
"electron-builder": "25.1.8",
"electron-builder": "^24.13.3",
"electron-devtools-installer": "^4.0.0"
}
}
}

View File

@@ -11,7 +11,7 @@ if (isDev) {
}
const { format } = require('url');
const { BrowserWindow, app, session, Menu, globalShortcut, ipcMain } = require('electron');
const { BrowserWindow, app, session, Menu, ipcMain } = require('electron');
const { setContentSecurityPolicy } = require('electron-util');
if (isDev && process.env.ELECTRON_USER_DATA_PATH) {
@@ -168,19 +168,6 @@ app.on('ready', async () => {
}
return { action: 'deny' };
});
// Quick fix for Electron issue #29996: https://github.com/electron/electron/issues/29996
globalShortcut.register('Ctrl+=', () => {
mainWindow.webContents.setZoomLevel(mainWindow.webContents.getZoomLevel() + 1);
});
globalShortcut.register('CommandOrControl+M', () => {
mainWindow.minimize();
});
globalShortcut.register('CommandOrControl+H', () => {
mainWindow.minimize();
});
mainWindow.webContents.on('did-finish-load', async () => {
let ogSend = mainWindow.webContents.send;

View File

@@ -9,11 +9,11 @@ async function resolveAwsV4Credentials(request) {
const awsv4 = request.awsv4config;
if (isStrPresent(awsv4.profileName)) {
try {
credentialsProvider = fromIni({
const credentialsProvider = fromIni({
profile: awsv4.profileName,
ignoreCache: true
});
credentials = await credentialsProvider();
const credentials = await credentialsProvider();
awsv4.accessKeyId = credentials.accessKeyId;
awsv4.secretAccessKey = credentials.secretAccessKey;
awsv4.sessionToken = credentials.sessionToken;

View File

@@ -129,6 +129,7 @@ const prepareRequest = async (item, collection, environment, runtimeVariables, c
const collectionRoot = collection?.draft ? get(collection, 'draft', {}) : get(collection, 'root', {});
const headers = {};
const url = request.url;
let contentTypeDefined = false;
each(get(collectionRoot, 'request.headers', []), (h) => {
if (h.enabled && h.name?.toLowerCase() === 'content-type') {
@@ -186,7 +187,7 @@ const prepareRequest = async (item, collection, environment, runtimeVariables, c
if (grpcRequest.oauth2) {
let requestCopy = cloneDeep(grpcRequest);
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
let credentials, credentialsId;
let credentials, credentialsId, oauth2Url, debugInfo;
switch (grantType) {
case 'authorization_code':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);

View File

@@ -102,7 +102,7 @@ const configureRequest = async (
if (request.oauth2) {
let requestCopy = cloneDeep(request);
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
let credentials, credentialsId;
let credentials, credentialsId, oauth2Url, debugInfo;
switch (grantType) {
case 'authorization_code':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);

View File

@@ -125,6 +125,12 @@ class Bru {
throw new Error('Creating a env variable without specifying a name is not allowed.');
}
if (variableNameRegex.test(key) === false) {
throw new Error(
`Variable name: "${key}" contains invalid characters! Names must only contain alpha-numeric characters, "-", "_", "."`
);
}
// When persist is true, only string values are allowed
if (options?.persist && typeof value !== 'string') {
throw new Error(`Persistent environment variables must be strings. Received ${typeof value} for key "${key}".`);
@@ -133,7 +139,7 @@ class Bru {
this.envVariables[key] = value;
if (options?.persist) {
this.persistentEnvVariables[key] = value
this.persistentEnvVariables[key] = value;
} else {
if (this.persistentEnvVariables[key]) {
delete this.persistentEnvVariables[key];

View File

@@ -0,0 +1,74 @@
const Bru = require('../src/bru');
describe('Bru.setEnvVar', () => {
const makeBru = () =>
new Bru(
/* envVariables */ {},
/* runtimeVariables */ {},
/* processEnvVars */ {},
/* collectionPath */ '/',
/* historyLogger */ undefined,
/* setVisualizations */ undefined,
/* secretVariables */ {},
/* collectionVariables */ {},
/* folderVariables */ {},
/* requestVariables */ {},
/* globalEnvironmentVariables */ {},
/* oauth2CredentialVariables */ {},
/* iterationDetails */ {},
/* collectionName */ 'Test'
);
test('updates envVariables and does not mark persistent when persist=false', () => {
const bru = makeBru();
bru.setEnvVar('non_persist', 'value', { persist: false });
expect(bru.envVariables.non_persist).toBe('value');
expect(bru.persistentEnvVariables.non_persist).toBeUndefined();
});
test('updates envVariables and tracks persistent when persist=true (string only)', () => {
const bru = makeBru();
bru.setEnvVar('persist_me', 'value', { persist: true });
expect(bru.envVariables.persist_me).toBe('value');
expect(bru.persistentEnvVariables.persist_me).toBe('value');
});
test('updates envVariables when options are omitted (defaults to non-persistent)', () => {
const bru = makeBru();
bru.setEnvVar('no_options', 'value');
expect(bru.envVariables.no_options).toBe('value');
expect(bru.persistentEnvVariables.no_options).toBeUndefined();
});
test('throws when persist=true but value is not a string', () => {
const bru = makeBru();
expect(() => bru.setEnvVar('persist_me', 123, { persist: true })).toThrow(
/Persistent environment variables must be strings/
);
});
test('changing existing key to non-persistent removes prior persisted entry', () => {
const bru = makeBru();
bru.setEnvVar('same_key', 'old', { persist: true });
expect(bru.persistentEnvVariables.same_key).toBe('old');
bru.setEnvVar('same_key', 'new');
expect(bru.envVariables.same_key).toBe('new');
expect(bru.persistentEnvVariables.same_key).toBeUndefined();
});
test('changing existing key to persistent updates persisted value', () => {
const bru = makeBru();
bru.setEnvVar('same_key', 'old');
expect(bru.persistentEnvVariables.same_key).toBeUndefined();
bru.setEnvVar('same_key', 'new', { persist: true });
expect(bru.envVariables.same_key).toBe('new');
expect(bru.persistentEnvVariables.same_key).toBe('new');
});
test('validates key name - invalid characters are rejected', () => {
const bru = makeBru();
expect(() => bru.setEnvVar('invalid key', 'v')).toThrow(/contains invalid characters/);
});
});

View File

@@ -94,9 +94,9 @@ flatpak install com.usebruno.Bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo apt update && sudo apt install gpg curl
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" | gpg --dearmor | sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```