Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/linux-deb.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Builds the Linux .deb on pull requests and manual runs. Does not publish a
# GitHub Release. The published-release upload lives in release.yml.
name: Linux deb

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
build-deb:
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm

- name: Install Debian packaging tools
run: sudo apt-get update && sudo apt-get install -y fakeroot dpkg

- name: Install dependencies
run: npm ci

- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
key: ${{ runner.os }}-electron-${{ hashFiles('package-lock.json') }}

- name: Build deb
run: npm run build:deb -- --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'

- name: Upload deb artifact
uses: actions/upload-artifact@v4
with:
name: basil-deb
path: release/Basil-*-amd64.deb
if-no-files-found: error
67 changes: 64 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Builds Windows portable + setup EXEs when a GitHub Release is published
# (not on push, PR, or draft release creation). Local `npm run build` is unchanged.
# Builds Windows portable + setup EXEs, and the Linux .deb, when a GitHub
# Release is published (not on push, PR, or draft release creation).
# Local `npm run build` is unchanged. Pull-request deb builds live in linux-deb.yml.
name: Release

on:
Expand Down Expand Up @@ -33,7 +34,7 @@ jobs:
$tag = '${{ github.event.release.tag_name }}'
$version = $tag -replace '^v', ''
if ($version -notmatch '^\d+\.\d+\.\d+') {
throw "Release tag '$tag' must be a semver version (e.g. 26.7.1 or v26.7.1)."
throw "Release tag '$tag' must be a semver version (e.g. 26.10.1 or v26.10.1)."
}
npm version $version --no-git-tag-version --allow-same-version
Write-Host "Building as version $version"
Expand Down Expand Up @@ -67,3 +68,63 @@ jobs:
}
gh release upload $tag $portable.FullName $setup.FullName --clobber
Write-Host "Uploaded $($portable.Name) and $($setup.Name) to $tag"

build-linux:
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm

- name: Install Debian packaging tools
run: sudo apt-get update && sudo apt-get install -y fakeroot dpkg

- name: Install dependencies
run: npm ci

- name: Sync package version from release tag
run: |
tag="${{ github.event.release.tag_name }}"
version="${tag#v}"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "Release tag '$tag' must be a semver version (e.g. 26.10.1 or v26.10.1)." >&2
exit 1
fi
npm version "$version" --no-git-tag-version --allow-same-version
echo "Building as version $version"

- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
key: ${{ runner.os }}-electron-${{ hashFiles('package-lock.json') }}

- name: Build deb
# --publish never: on tagged commits electron-builder otherwise tries to
# publish and fails without GH_TOKEN. The asset is uploaded via gh below.
run: npm run build:deb -- --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'

- name: Upload deb to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${{ github.event.release.tag_name }}"
deb="$(find release -maxdepth 1 -type f -name 'Basil-*-amd64.deb' | head -n 1)"
if [[ -z "$deb" ]]; then
ls -la release || true
echo "Expected Basil-*-amd64.deb in release/" >&2
exit 1
fi
gh release upload "$tag" "$deb" --clobber
echo "Uploaded $(basename "$deb") to $tag"
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ yarn-error.log*
pnpm-debug.log*

# Local environment and secrets.
# Basil keeps its API key in %USERPROFILE%\.basil\ai.json, outside the repo, but
# Basil keeps its API key in %USERPROFILE%\.basil\ai.json (or ~/.basil/ai.json), outside the repo, but
# never let a stray local env file be committed.
.env
.env.*
Expand Down
59 changes: 47 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Basil

**Basil** is an intuitive text editor for Windows with optional AI assistance. Open source and free!
**Basil** is an intuitive text editor for Windows, with a `.deb` package for Ubuntu 26.04 and newer, and optional AI assistance. Open source and free!

[github.com/michaelstaake/Basil](https://github.com/michaelstaake/Basil)

Versioning is year, month, then build.
Versioning is year, month, then build. The current release is 26.10.1.

## Get Started

Expand All @@ -14,23 +14,31 @@ Grab the latest `Basil-<version>-setup.exe` (installer) or

At this time, the executables are not signed, so you may receive Windows SmartScreen warnings. Choose run anyway, or build from source using the steps below (refer to Build section of this document).

On Ubuntu 26.04 or newer (x64), install the `.deb` the same way:

```bash
sudo apt install ./Basil-<version>-amd64.deb
```

`apt` pulls in GTK and the other libraries the package depends on. Basil then shows up in the applications menu, and under **Open With** for common text files. The current default text editor stays as it is.

## Features
- Built on Monaco editor, which powers popular applications like VS Code.
- Powerful yet easy handling of multiple files with tabs.
- Fully customizable toolbar - make it just the way you want it.
- Dark mode and light mode -the default is to detect the correct theme based on your system preferences.
- Automatically detects correct encoding and line endings.
- Compare your current file state to the original file state with git-style diff feature.
- Optional AI features - compatible with any OpenAI API-compatible provider or service, including LM Studio, LmPanel, OpenRouter, xAI, etc.
- Optional AI features, off until you turn them on in Settings. Compatible with any OpenAI API-compatible provider or service, including LM Studio, Napa AI Gateway, xAI/SpaceXSI, and custom endpoints such as LmPanel or OpenRouter.
- If enabled, each file gets its own AI context that can assist you. AI features are specifically designed to be read-only so you always remain in complete control.
- Basil comes packaged with a portable version, or install it for convenient features like "Open with Basil" context menu, system tray persistence, etc. Alternately, you may build it from source.
- Basil comes packaged with a portable version, or install it for convenient features like "Open with Basil" context menu, system tray persistence, etc. The Ubuntu `.deb` adds an applications-menu entry and Open With for common text files. Alternately, you may build it from source.

Hint: Click the Basil logo in the toolbar to access settings and more features.

## Requirements

- Windows 10/11
- Node.js 22+ (if building from source - otherwise you can just run the included portable or setup executables)
- Windows 10/11, or Ubuntu 26.04+ (x64) for the `.deb`
- Node.js 22+ (if building from source - otherwise you can just run the included portable or setup executables, or install the `.deb`)


## Development
Expand All @@ -50,7 +58,8 @@ Other scripts:
| `npm run typecheck` | TypeScript, no emit |
| `npm run lint` | ESLint |
| `npm test` | Unit tests (Vitest) |
| `npm run icons` | Regenerate `build/icon.png` and `build/icon.ico` from `build/icon.svg` |
| `npm run build:deb` | Linux `.deb` for x64 (`release/Basil-<version>-amd64.deb`) |
| `npm run icons` | Regenerate `build/icon.png`, `build/icon.ico`, and `build/icons/` from `build/icon.svg` |

## Build

Expand All @@ -61,16 +70,26 @@ npm run build
Writes `release/Basil-<version>-portable.exe` and `release/Basil-<version>-setup.exe`.
Use `npm run build:dir` for an unpacked build without installers.

The Linux package is a separate command. `npm run build` still writes the Windows executables above.

```bash
npm run build:deb
```

Writes `release/Basil-<version>-amd64.deb` (Debian package name `basil`, executable `/usr/bin/basil`). Run it on Linux. electron-builder shells out to `dpkg` and `fakeroot` to assemble the package. The `.deb` depends on the Ubuntu 26.04 library names (`libgtk-3-0t64` and the other `t64` packages), with the older names as alternatives where those still exist. It also depends on glibc 2.25 or newer, which Ubuntu 26.04 (glibc 2.43) satisfies, and on `libudev1`, which the Electron binary links directly. The only Linux target is this `.deb`.

Everything the app needs at runtime is bundled into `dist/` by Vite, so
`package.json` has **no production dependencies**. Adding one would ship it a
second time inside `app.asar` — keep new libraries in `devDependencies` unless
they genuinely need to be loaded from disk at runtime.

## Configuration

On first launch Basil creates a hidden folder in your home directory:
On first launch Basil creates a folder in your home directory:

`%USERPROFILE%\.basil\` on Windows, `~/.basil/` on Linux.

`%USERPROFILE%\.basil\`
On Windows that folder is marked hidden. On Linux the leading dot is what hides it.

| File | Purpose |
|------|---------|
Expand All @@ -79,18 +98,34 @@ On first launch Basil creates a hidden folder in your home directory:
| `session.json` | Files to reopen on next launch |
| `logs\main.log` | Main-process log, including crashes |

The API key is encrypted with the Windows credential store (DPAPI) and stored as
The API key is encrypted with the OS keystore (Windows DPAPI, or the Secret Service on Linux) and stored as
`apiKeyEnc`. A file that cannot be parsed is renamed with a `.corrupt-<timestamp>`
suffix and Basil starts with defaults rather than failing to launch.
suffix and Basil starts with defaults rather than failing to launch. When a Linux Secret Service is available, the key is encrypted with it. Otherwise Basil stores the key in the file so the setting is kept.

### Linux startup

"Start with boot" on Linux writes `~/.config/autostart/com.basil.editor.desktop` (or `$XDG_CONFIG_HOME/autostart/` when that variable is set). Electron's login-item API does not run on Linux, so this is a normal XDG autostart file. It launches `/opt/Basil/basil --hidden`, the same hidden start the Windows Run key uses. Desktops that honor that directory (GNOME, KDE, XFCE, and other freedesktop sessions) will start Basil in the tray.

A few limits:

- A session that ignores `autostart` will not launch Basil at login.
- The tray icon is a PNG. It needs a StatusNotifier host. Ubuntu's desktop includes one (`libayatana-appindicator3-1` is a recommended package). A minimal session with no tray still starts Basil hidden, with no window to click. Turn the setting off, or delete the autostart file, and open Basil from the menu.
- The package install leaves that file alone, and removing the `.deb` leaves it in place. `TryExec` points at the binary, so a removed install is skipped on later logins.
- `npm run dev` removes this file so a dev Electron binary stays off the login list. The Windows NSIS login item is unchanged.

System light and dark mode on Linux follows Electron's `nativeTheme` (the GTK / portal setting).

### AI presets

| Preset | Default base URL |
|--------|------------------|
| LM Studio | `http://localhost:1234/v1` |
| xAI | `https://api.x.ai/v1` |
| Napa AI Gateway | `https://gateway-api.napa-ai.com/v1` |
| xAI/SpaceXSI | `https://api.x.ai/v1` |
| Custom | whatever you set |

AI is off on a fresh install. Turn on **Enable AI integration** in Settings to use a preset. A saved choice in `preferences.json` is kept.

Use **Test connection** in Settings to check an endpoint. If you start your model
server after Basil, the AI controls appear when the window regains focus.

Expand Down
Binary file added build/icons/128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/16x16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/22x22.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/24x24.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/256x256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/48x48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions electron/host-platform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
LINUX_DESKTOP_NAME,
linuxAutostartDesktopPath,
quoteDesktopExecArg,
renderLinuxAutostartDesktop,
shouldHideBasilDir,
trayIconCandidates,
} from './host-platform';

const trayLocations = {
devIco: 'dev.ico',
devPng: 'dev.png',
resourceIco: 'res.ico',
resourcePng: 'res.png',
};

describe('shouldHideBasilDir', () => {
it('uses attrib only on Windows', () => {
expect(shouldHideBasilDir('win32')).toBe(true);
expect(shouldHideBasilDir('linux')).toBe(false);
expect(shouldHideBasilDir('darwin')).toBe(false);
});
});

describe('trayIconCandidates', () => {
it('searches PNG before ICO on Linux', () => {
expect(trayIconCandidates('linux', trayLocations)).toEqual([
'res.png',
'dev.png',
'res.ico',
'dev.ico',
]);
});

it('keeps the Windows ico, png, resource-ico order', () => {
expect(trayIconCandidates('win32', trayLocations)).toEqual([
'dev.ico',
'dev.png',
'res.ico',
]);
});
});

describe('linux autostart desktop file', () => {
it('places the file under XDG_CONFIG_HOME when that is set', () => {
expect(linuxAutostartDesktopPath('/home/me', '/tmp/cfg')).toBe(
path.join('/tmp/cfg', 'autostart', `${LINUX_DESKTOP_NAME}.desktop`),
);
});

it('falls back to ~/.config/autostart', () => {
expect(linuxAutostartDesktopPath('/home/me', ' ')).toBe(
path.join('/home/me', '.config', 'autostart', `${LINUX_DESKTOP_NAME}.desktop`),
);
});

it('launches the packaged binary hidden, matching the Windows Run key', () => {
const text = renderLinuxAutostartDesktop({
execPath: '/opt/Basil/basil',
hidden: true,
});
expect(text.startsWith('[Desktop Entry]\n')).toBe(true);
expect(text).toContain('Exec=/opt/Basil/basil --hidden\n');
expect(text).toContain('TryExec=/opt/Basil/basil\n');
expect(text).toContain('X-GNOME-Autostart-enabled=true\n');
expect(text.endsWith('\n')).toBe(true);
});

it('quotes an executable path that contains spaces', () => {
expect(quoteDesktopExecArg('/opt/My Apps/basil')).toBe('"/opt/My Apps/basil"');
const text = renderLinuxAutostartDesktop({
execPath: '/opt/My Apps/basil',
hidden: true,
});
expect(text).toContain('Exec="/opt/My Apps/basil" --hidden\n');
});

it('escapes percent signs so the launcher does not treat them as field codes', () => {
expect(quoteDesktopExecArg('/opt/100%/basil')).toBe('"/opt/100%%/basil"');
});

it('leaves freedesktop field codes unquoted', () => {
expect(quoteDesktopExecArg('%U')).toBe('%U');
expect(quoteDesktopExecArg('%f')).toBe('%f');
});
});
Loading
Loading