From b7acf414101d7aef3bfd1c1b388ccc66385a2fc3 Mon Sep 17 00:00:00 2001 From: Michael Staake Date: Tue, 27 Oct 2026 13:31:53 +0000 Subject: [PATCH 1/4] Default AI features to off on a fresh install New preferences get aiEnabled false, so the Settings checkbox starts unchecked. An existing preferences.json keeps an explicit true or false. --- shared/config.test.ts | 8 ++++++++ shared/config.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/shared/config.test.ts b/shared/config.test.ts index 2d57460..83d3e9e 100644 --- a/shared/config.test.ts +++ b/shared/config.test.ts @@ -17,6 +17,14 @@ import { describe('normalizePreferences', () => { it('returns defaults for an empty object', () => { expect(normalizePreferences({})).toEqual(DEFAULT_PREFERENCES); + expect(DEFAULT_PREFERENCES.aiEnabled).toBe(false); + }); + + it('keeps an explicit aiEnabled and defaults when the key is missing', () => { + expect(normalizePreferences({}).aiEnabled).toBe(false); + expect(normalizePreferences({ aiEnabled: true }).aiEnabled).toBe(true); + expect(normalizePreferences({ aiEnabled: false }).aiEnabled).toBe(false); + expect(normalizePreferences({ aiEnabled: 'yes' as never }).aiEnabled).toBe(false); }); it('rejects an invalid theme and falls back to the default', () => { diff --git a/shared/config.ts b/shared/config.ts index 65ec062..3af31a8 100644 --- a/shared/config.ts +++ b/shared/config.ts @@ -128,7 +128,8 @@ export function resolveEditorFontFamily(id: EditorFontId): string { export const DEFAULT_PREFERENCES: Preferences = { theme: 'system', - aiEnabled: true, + // Off until the user opts in. A saved true or false is kept. + aiEnabled: false, wordWrap: true, indentGuides: true, highlightCurrentLine: true, @@ -179,6 +180,8 @@ export function normalizePreferences(raw: Partial & Record Date: Tue, 27 Oct 2026 13:36:31 +0000 Subject: [PATCH 2/4] Add an Ubuntu .deb package for Basil electron-builder produces Basil--amd64.deb for x64, with a desktop entry, MIME types, and hicolor icons. Windows portable and NSIS builds stay on the existing scripts. Linux skips attrib and the registry theme probe, uses a PNG tray icon, and writes an XDG autostart file for start-with-boot. --- .github/workflows/linux-deb.yml | 51 ++++++++++++++++++ .github/workflows/release.yml | 65 ++++++++++++++++++++++- .gitignore | 2 +- README.md | 50 ++++++++++++++---- build/icons/128x128.png | Bin 0 -> 3700 bytes build/icons/16x16.png | Bin 0 -> 553 bytes build/icons/22x22.png | Bin 0 -> 705 bytes build/icons/24x24.png | Bin 0 -> 790 bytes build/icons/256x256.png | Bin 0 -> 8232 bytes build/icons/32x32.png | Bin 0 -> 951 bytes build/icons/48x48.png | Bin 0 -> 1398 bytes build/icons/512x512.png | Bin 0 -> 21864 bytes build/icons/64x64.png | Bin 0 -> 1842 bytes electron/host-platform.test.ts | 88 ++++++++++++++++++++++++++++++++ electron/host-platform.ts | 87 +++++++++++++++++++++++++++++++ electron/main.ts | 88 +++++++++++++++++++++++++++----- package.json | 86 +++++++++++++++++++++++++++++++ scripts/generate-icons.mjs | 22 ++++++-- scripts/with-build-version.mjs | 23 ++++++--- 19 files changed, 528 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/linux-deb.yml create mode 100644 build/icons/128x128.png create mode 100644 build/icons/16x16.png create mode 100644 build/icons/22x22.png create mode 100644 build/icons/24x24.png create mode 100644 build/icons/256x256.png create mode 100644 build/icons/32x32.png create mode 100644 build/icons/48x48.png create mode 100644 build/icons/512x512.png create mode 100644 build/icons/64x64.png create mode 100644 electron/host-platform.test.ts create mode 100644 electron/host-platform.ts diff --git a/.github/workflows/linux-deb.yml b/.github/workflows/linux-deb.yml new file mode 100644 index 0000000..514be59 --- /dev/null +++ b/.github/workflows/linux-deb.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adf0af8..63129ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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.7.1 or v26.7.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" diff --git a/.gitignore b/.gitignore index 908ddb5..20bd2ff 100644 --- a/.gitignore +++ b/.gitignore @@ -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.* diff --git a/README.md b/README.md index baefb07..dd3a035 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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) @@ -14,6 +14,14 @@ Grab the latest `Basil--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--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. @@ -23,14 +31,14 @@ At this time, the executables are not signed, so you may receive Windows SmartSc - 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. - 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 @@ -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--amd64.deb`) | +| `npm run icons` | Regenerate `build/icon.png`, `build/icon.ico`, and `build/icons/` from `build/icon.svg` | ## Build @@ -61,6 +70,14 @@ npm run build Writes `release/Basil--portable.exe` and `release/Basil--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--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. Ubuntu 26.04 ships glibc 2.43, which satisfies the package's libc requirement. 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 @@ -68,9 +85,11 @@ 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\` +`%USERPROFILE%\.basil\` on Windows, `~/.basil/` on Linux. + +On Windows that folder is marked hidden. On Linux the leading dot is what hides it. | File | Purpose | |------|---------| @@ -79,9 +98,22 @@ 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-` -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 diff --git a/build/icons/128x128.png b/build/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..9365061a50b7ff4db3e3923fda016594ee5ef7b8 GIT binary patch literal 3700 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 literal 0 HcmV?d00001 diff --git a/build/icons/24x24.png b/build/icons/24x24.png new file mode 100644 index 0000000000000000000000000000000000000000..0f9c666147acdcf6ccde3102d6e9cbe026112f88 GIT binary patch literal 790 zcmV+x1L^#UP)QxhH%3{g1s_n}R4j@` zqr|u%ZWIt>hZIPZpzJiUKzOKe#YQ12&joELCPFI+f*2vhfC-x>KXYbmG|Xin3X9)n zCi%XbGxwY`j4{S66_)*mN?V=LZ42w&)`;P<4TcIU^ps0hq+C3NlyWIL*FoqmP|~3- zONaJ6#R84f0!?XJL{nl8t4qwaF%H{47KIxtZNo-)1`KW+{vB>10n{aFP#2q#T%0;2 zN~DwTE?iB5A~RHlrW!_N*$a4bVJcJwrV!)R{BTt!?)e%ZbT+}$(fB9r(Rgv+=S4wn znF5?*j4z@huxv{ow{2{*r+yqs=35-{khR)_42_|PIMKG1g;Ihf!NqHtPUd^dA z1Q-bPqi?j2o3ASiqQH9NC|q6FaJ(j422aW}g7H46&>;yvFU=slI4#S!)g|%$P-q0V zM(dH&>cmNJo(!JkFoN-JOH}Y-b`pWGUzYjrb6DS4Lv!#kTs_xO-dlx?o5y7E#9R_^ zu3}U$IPnhspB*zQWC``98S1 zs*uy>L{?J)7oM170OK{0(B8P^M-I+Ri0`^)U*eS40jKW@RQah~c)~e77_URAnFzAD z{6(zy=Fs!Dkte8rQG?{7v@PMk1@iXy_#}ZpG>);4qq5NRH-P~JKLr)x31{_SyxHRM zc}2ne@|<#`kw5_+e}XU3rUQ!bI1=zi?vbmR zXzp%7%kxKg*!x#{D6L{1bho0ku3Qlwm#qU6*PP07*qoM6N<$g8x5b1^@s6 literal 0 HcmV?d00001 diff --git a/build/icons/256x256.png b/build/icons/256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..de2e982e34aabda0bccd91468a5db160b40509c7 GIT binary patch literal 8232 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 literal 0 HcmV?d00001 diff --git a/build/icons/32x32.png b/build/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..f9b6f8ea2c693a1cd372be49b12772261f9ed47b GIT binary patch literal 951 zcmV;o14#UdP)$O>&#j(;cV#oq@(y`QWbkuPx zol#ssBQr!LDo82lArh?~T9~LP(1+3-jm&M-#;LRtH3UU5b7{tU{KdJ;+>p0z+FNXlmkl;gmrEi1fj60?X@5 zFugEE%#XzxPX)AfsZjN<9+GbwA@g1#FPuu}1bDu7PGEj{4#uY)y!kxq1lOMpdhN>Q zPq^5B1vPcHY=(-i%aGV?phZ-c2?~by`Bd8wtO(RR}tZ%X{-QWhvNi(=xc!J%2*3zOBXIj$8)%yhV0>5viOm^a;UTG<08ekX+86<$x#etu z_^T;kx^ET)4@s2)A{{K8fRILn*Hk{JfsV=7pecb&R8C-FWqwCnp(%+I1P?wN&I<6H=bS*_mk&D@5CUfe2$zXnSHKb{2p)Wf z7T~|hJnpjY>Nq}Dd`b{J2n&#|QE7Eq&}n}Qwy_>2qua&iN%x2i?!IaT3YT91JSbVp z3JAQzkxn(Cd%hJ;3y4gSE?>a|b4Qts-MkXbUQX zwjhWa<~AlIl1VOG5_2IOc9F|GQ6;W6u_9U&qcr# zhEFU@gVbsr1&RVs3E)&4rhwD!gtL8LV`p@7m?7Yz;dgyn0M$Rd4_j-qKx|SY!3zr% zK;V>0roimu2QdENCU)k%e?2YWy8TM$B^NLZ!-lD>O9v&$T`vj zWfyBH_;SZ#$UdI4m;j_svgS~$W)b?~Ja|Gsr{&XQSydYm56hPeDFa2h@YuI!K-S4U zP(OAOte1~q!3*A^1<-btOo3Ium+DR2c%Cdl)9o{qz`k=;pli@WUP~$UU3)YW3qB}- zz9r^j4m@0)rH4;yjSjLt&V^&6O<+D-fejx=31IB!nF9U4{s@l2^Uuts>yC)UYsx!K z!B@KKKwFmyCFksL=xRMk&1!7;xI8%!IEN9Yz@6W3Bh~+r$>E6ddB-~yTy=MX;d3J# z9cl!n^|ejG$K@pk1rSeCTnwSREBE2ZU;}6lXF}81X{zdM7Cs<=aSUV%%*;*0FEcl= za(8YTyla2Mj*s%8-qi$`hA*>%kK0QNpq*iv0#`=oWIU@&P~5nm6?~)s;zet6`8L>Ivy0B_ z)r=h6Nk>O#2k5E-*+!<4%$tx6R|NS2&PVuYy%eGhvhCDMrP!pp$~*WR=^*`1 z9B)>{icLxnU*Mzl67MSpS@@xwY~=Gj`2x-d_*ZkJk$*J^HS&)Xq9WzM%L*T*mtK3S z=QnEP3pn|REHoxfkx!W~tj_DDezH_X>!mI9g#BOp4;n;awZJ+@)&Kwi07*qoM6N<$ Eg8R#gPyhe` literal 0 HcmV?d00001 diff --git a/build/icons/512x512.png b/build/icons/512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..8d300126b11f77ba8f948d454141c618b48feb37 GIT binary patch literal 21864 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 AfdBvi literal 0 HcmV?d00001 diff --git a/build/icons/64x64.png b/build/icons/64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..4c4d3a3732da31b1f5f669efefa093a5a75a3201 GIT binary patch literal 1842 zcmV-22hI42P)|C z2B~|p0VO;0n_+i`1J9G3v+tg>hako|zk#%NrKp-B$(lwLZ1{Qg4{RxzDWRiUINH`lj}`99qAJPkoNdtsa@SKwW&L$HuZ9m zF=0Bv^`IP@X-b!7m%^nPQ98AtM9|(m!|+l&N*oi?76+s?Zj`jD50JT3)KkI7y0iIbA%oiQ%F!lhlIbZSSZ8a!!LdtycT%tj9EPz z@J&#ZCPhreuYCZ##S@8H{eU#7Ut$C(otnMj0#C=R0*Q&*&D`py#Awd}-VY*j=M zN~g9g{NNwOM3oQQO-!x7#ifN~STz*p?r z00XxMVEEq$AI-;>moNe_2E0iVz$3gk2MNF&?I%UR81SSu z4#bNoEO;>zfP1U6HwOy%{m!p&?Z(xhgW3rYQ0Ff|mYWC}@0cNF)f|X-y^IAf&QbaZ z;CP?6u>{<@cN2CWd7rnS_MRj_Q;`nk$E%^ZeHF;9i7=-w0}Ecv2*4HgW4NQ0Q%Ice{6y3b1gs>ra;gd^QwVF&#_YKIMf@opc~oZrj{c&%X(yi%oO&SUeFvEW4>0eBBK zYyp1`-GY6c9|bk=q2IqVblb1pd&76(?Yu<86;3~XgrE#3DG z+sw>0{T3rOyvR=g$LIO61n^Y|{e0spJr=YNcKChzbL%r&g8HK@L33T zU+jXO-+KM~eseaYRqG(NP6t(Io9JsP73o32i~Iy|oW`;>hIS12_Qr7retk~^#AK-; ztyT{?d-LgQ1qaK5h8HmcFy`<=E#UKkc1FOWZ8m!3pIvW&4QHBQ?a6vj7HNZqkER7+ zPGH&^3wbMq`mSFL5U}^!r!@S=v&|s4CBW=@JruN+(gR=WMjbEk(U}S$aA$AX0)i=o zP@iWD*nRb5T7Y>^5hSlnhm7r>Ti=4M7T(~aGh=-OaGbifHuwwRJ0D)#PXnu#ra)Sq z9;(hZ(_Mj2_-IA|#=L!~1+-m10!3{rfhyEORc|Ad?62e7jE~_?VD=yc!!UE zEP(rR6oCy`6tH|UXJ}N^F1nvt-@+Apy`f|^Nz@O?n4KYR)@9A554D??QQ>)gWl{2$TpNOJgIEm0I}ADkibXz2?)`vXu&L6grd*;!bdRz!W2Hr zAP0i9$GgK7USN>lCrDe|m2iYdi8RPA6H=}|Qp{70BR~H`edfENRxzZQ?c83Y&I=H5ZlqAElC>ZUS$X%ad(fBR&@s2S2rcU_6Dh*c$!0; zt$HEGOuZ{HQxBhec%5uipp)(k=<`n{!m5ZsTg${I>T9u)x{UVNJnQfRz08d&JXGWe g^wMR_t?miOe`eWlypl1H@c;k-07*qoM6N<$g4Z5fvj6}9 literal 0 HcmV?d00001 diff --git a/electron/host-platform.test.ts b/electron/host-platform.test.ts new file mode 100644 index 0000000..a3d7818 --- /dev/null +++ b/electron/host-platform.test.ts @@ -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'); + }); +}); diff --git a/electron/host-platform.ts b/electron/host-platform.ts new file mode 100644 index 0000000..e5ea096 --- /dev/null +++ b/electron/host-platform.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; + +/** + * Installed desktop id. electron-builder writes + * `/usr/share/applications/com.basil.editor.desktop` when `desktopName` is set + * and `linux.syncDesktopName` is true. Electron uses the same id for WM_CLASS. + */ +export const LINUX_DESKTOP_NAME = 'com.basil.editor'; + +/** Windows marks `~\.basil` hidden with `attrib +h`. Other platforms use the leading dot. */ +export function shouldHideBasilDir(platform: NodeJS.Platform): boolean { + return platform === 'win32'; +} + +export interface TrayIconLocations { + devIco: string; + devPng: string; + resourceIco: string; + resourcePng: string; +} + +/** + * Linux status icons want a PNG. The packaged file is preferred so a dev tree + * is not required after install; Windows keeps the `.ico` search it already had. + */ +export function trayIconCandidates( + platform: NodeJS.Platform, + locations: TrayIconLocations, +): string[] { + if (platform === 'linux') { + return [ + locations.resourcePng, + locations.devPng, + locations.resourceIco, + locations.devIco, + ]; + } + return [locations.devIco, locations.devPng, locations.resourceIco]; +} + +/** Characters that must be quoted in a freedesktop `Exec` value. */ +const DESKTOP_EXEC_RESERVED = /[\s"'`\\<>~|&;$*?#()%]/; + +/** + * Quote one `Exec` argument. Field codes (`%f`, `%U`, …) stay bare so the + * launcher expands them. `%` inside a real argument is escaped as `%%`. + */ +export function quoteDesktopExecArg(arg: string): string { + if (/^%[a-zA-Z]$/.test(arg)) return arg; + const escaped = arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%'); + if (DESKTOP_EXEC_RESERVED.test(arg)) return `"${escaped}"`; + return escaped; +} + +export function linuxAutostartDesktopPath(home: string, xdgConfigHome: string | undefined): string { + const configHome = xdgConfigHome?.trim() || path.join(home, '.config'); + return path.join(configHome, 'autostart', `${LINUX_DESKTOP_NAME}.desktop`); +} + +/** + * Per-user XDG autostart entry. Electron's `setLoginItemSettings` is + * macOS/Windows only, so Linux login startup is this file. + * `--hidden` matches the Windows Run key (`basil` → `"exe" --hidden`). + */ +export function renderLinuxAutostartDesktop(options: { + execPath: string; + hidden: boolean; +}): string { + const exec = [ + quoteDesktopExecArg(options.execPath), + ...(options.hidden ? ['--hidden'] : []), + ].join(' '); + return [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + 'Name=Basil', + 'Comment=Basil Text Editor', + `Exec=${exec}`, + `TryExec=${quoteDesktopExecArg(options.execPath)}`, + 'Terminal=false', + 'Icon=basil', + 'X-GNOME-Autostart-enabled=true', + 'StartupNotify=false', + '', + ].join('\n'); +} diff --git a/electron/main.ts b/electron/main.ts index 28456a9..c8913bf 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -54,10 +54,22 @@ import { decode, detectEncoding, normalizeEncoding } from '../shared/encoding'; import { capChatHistory } from '../shared/ai'; import type { LineEnding } from '../shared/text'; import { getMtimeMs, readTextFile, writeTextFile } from './file-io'; +import { + LINUX_DESKTOP_NAME, + linuxAutostartDesktopPath, + renderLinuxAutostartDesktop, + shouldHideBasilDir, + trayIconCandidates, +} from './host-platform'; import { initLogger, installGlobalErrorHandlers } from './logger'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +if (process.platform === 'linux') { + // Match the installed com.basil.editor.desktop (package.json desktopName). + app.setDesktopName(LINUX_DESKTOP_NAME); +} + const windows = new Map(); /** Windows allowed to close without asking the renderer. */ const allowCloseIds = new Set(); @@ -340,7 +352,35 @@ function clearLoginItem(name: string): void { }); } +function applyLinuxAutostart(): void { + // setLoginItemSettings is macOS and Windows only. Login startup on Linux is + // a per-user XDG autostart file. Limits are documented in the README. + const target = linuxAutostartDesktopPath(app.getPath('home'), process.env.XDG_CONFIG_HOME); + try { + // Unpackaged dev runs must not register node_modules/electron, and must + // drop a stale entry the way the Windows Run key is cleared. + if (!app.isPackaged || !preferences.startWithBoot) { + fsSync.rmSync(target, { force: true }); + return; + } + const contents = renderLinuxAutostartDesktop({ + execPath: process.execPath, + hidden: true, + }); + if (fsSync.existsSync(target) && fsSync.readFileSync(target, 'utf8') === contents) return; + fsSync.mkdirSync(path.dirname(target), { recursive: true }); + fsSync.writeFileSync(target, contents, { encoding: 'utf8', mode: 0o644 }); + } catch (err) { + console.error('[basil] could not update the Linux autostart entry:', err); + } +} + function applyLoginItemSettings(): void { + if (process.platform === 'linux') { + applyLinuxAutostart(); + return; + } + // Drop legacy duplicates so boot never launches Basil twice (second instance // would focus the main window) and so stale electron.exe Run entries die. for (const name of LEGACY_LOGIN_ITEM_NAMES) { @@ -362,24 +402,41 @@ function applyLoginItemSettings(): void { }); } +function loadTrayIconFromFiles(): Electron.NativeImage { + const candidates = trayIconCandidates(process.platform, { + devIco: path.join(__dirname, '../build/icon.ico'), + devPng: path.join(__dirname, '../build/icon.png'), + resourceIco: path.join(process.resourcesPath, 'icon.ico'), + resourcePng: path.join(process.resourcesPath, 'icon.png'), + }); + for (const candidate of candidates) { + if (!fsSync.existsSync(candidate)) continue; + const image = nativeImage.createFromPath(candidate); + if (!image.isEmpty()) return image; + } + return nativeImage.createEmpty(); +} + async function ensureTray(): Promise { if (tray) return; let icon = nativeImage.createEmpty(); - try { - icon = await app.getFileIcon(process.execPath, { size: 'small' }); - } catch { - const candidates = [ - path.join(__dirname, '../build/icon.ico'), - path.join(__dirname, '../build/icon.png'), - path.join(process.resourcesPath, 'icon.ico'), - ]; - for (const candidate of candidates) { - if (fsSync.existsSync(candidate)) { - icon = nativeImage.createFromPath(candidate); - if (!icon.isEmpty()) break; + if (process.platform === 'linux') { + // getFileIcon is Windows/macOS-oriented and may return an empty image. + // The tray wants the packaged PNG. + icon = loadTrayIconFromFiles(); + if (!icon.isEmpty()) { + const { width, height } = icon.getSize(); + if (width > 32 || height > 32) { + icon = icon.resize({ width: 24, height: 24 }); } } + } else { + try { + icon = await app.getFileIcon(process.execPath, { size: 'small' }); + } catch { + icon = loadTrayIconFromFiles(); + } } tray = new Tray(icon); @@ -512,6 +569,7 @@ function getBasilDir(): string { } async function hideBasilDir(dir: string): Promise { + if (!shouldHideBasilDir(process.platform)) return; try { const { execFile } = await import('node:child_process'); const { promisify } = await import('node:util'); @@ -527,6 +585,9 @@ async function hideBasilDir(dir: string): Promise { * blocks the main process, and this is consulted on every theme read, every * window creation and every broadcast — so it is refreshed only when Windows * actually reports a theme change. + * + * Off Windows the probe is skipped (`null`) and the system theme comes from + * Electron's nativeTheme, which tracks the GTK / portal preference on Linux. */ let cachedAppsPreferDark: boolean | null | undefined; @@ -688,7 +749,8 @@ async function savePreferences(next: Preferences): Promise { /** * The API key is a real credential, so it is encrypted at rest with the OS - * keystore (DPAPI on Windows) rather than sitting in plaintext in ai.json. + * keystore (DPAPI on Windows, the Secret Service via libsecret on Linux) + * rather than sitting in plaintext in ai.json. * Files written by older versions still hold `apiKey` and are migrated on load. */ function hasEncryptedKey(raw: unknown): boolean { diff --git a/package.json b/package.json index ba5ad7c..191950e 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "url": "https://github.com/michaelstaake/Basil/issues" }, "main": "dist-electron/main.js", + "desktopName": "com.basil.editor", "type": "module", "scripts": { "dev": "vite", "build": "vite build && node scripts/with-build-version.mjs --win", "build:dir": "vite build && node scripts/with-build-version.mjs --win dir", + "build:deb": "vite build && node scripts/with-build-version.mjs --linux deb --x64", "icons": "node scripts/generate-icons.mjs", "preview": "vite preview", "typecheck": "tsc --noEmit", @@ -98,6 +100,90 @@ "createDesktopShortcut": true, "createStartMenuShortcut": true, "artifactName": "Basil-${version}-setup.${ext}" + }, + "linux": { + "target": [ + { + "target": "deb", + "arch": [ + "x64" + ] + } + ], + "icon": "icons", + "executableName": "basil", + "category": "Utility;TextEditor", + "synopsis": "Intuitive text editor", + "description": "An intuitive text editor with optional AI assistance.", + "maintainer": "Michael Staake ", + "syncDesktopName": true, + "mimeTypes": [ + "text/plain", + "text/markdown", + "text/x-markdown", + "text/html", + "text/css", + "text/javascript", + "application/javascript", + "application/json", + "application/xml", + "text/xml", + "text/x-python", + "text/x-shellscript", + "text/csv", + "text/yaml", + "application/x-yaml" + ], + "desktop": { + "entry": { + "Name": "Basil", + "GenericName": "Text Editor", + "Keywords": "text;editor;notes;markdown;", + "StartupNotify": "true" + } + }, + "extraResources": [ + { + "from": "build/icon.png", + "to": "icon.png" + } + ] + }, + "deb": { + "artifactName": "Basil-${version}-${arch}.${ext}", + "packageName": "basil", + "packageCategory": "editors", + "priority": "optional", + "depends": [ + "libc6 (>= 2.35)", + "libgtk-3-0t64 | libgtk-3-0", + "libglib2.0-0t64 | libglib2.0-0", + "libatk1.0-0t64 | libatk1.0-0", + "libatk-bridge2.0-0t64 | libatk-bridge2.0-0", + "libatspi2.0-0t64 | libatspi2.0-0", + "libasound2t64 | libasound2", + "libcups2t64 | libcups2", + "libdrm2", + "libgbm1", + "libnotify4", + "libnspr4", + "libnss3", + "libx11-xcb1", + "libxcb-dri3-0", + "libxcomposite1", + "libxdamage1", + "libxfixes3", + "libxkbcommon0", + "libxrandr2", + "libxss1", + "libxtst6", + "libsecret-1-0", + "libuuid1", + "xdg-utils" + ], + "recommends": [ + "libayatana-appindicator3-1" + ] } } } diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index ee83488..2bbb913 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -14,12 +14,28 @@ const svg = await fs.readFile(svgPath); await sharp(svg).resize(512, 512).png().toFile(pngPath); -const sizes = [16, 24, 32, 48, 64, 128, 256]; +const icoSizes = [16, 24, 32, 48, 64, 128, 256]; const buffers = await Promise.all( - sizes.map((size) => sharp(svg).resize(size, size).png().toBuffer()), + icoSizes.map((size) => sharp(svg).resize(size, size).png().toBuffer()), ); const ico = await pngToIco(buffers); await fs.writeFile(icoPath, ico); -console.log('Wrote build/icon.png and build/icon.ico'); +// hicolor sizes for the Linux .deb. electron-builder installs each +// NxN.png under /usr/share/icons/hicolor. The Windows .ico above is unchanged. +const iconDir = path.join(root, 'build', 'icons'); +await fs.mkdir(iconDir, { recursive: true }); +const linuxSizes = [16, 22, 24, 32, 48, 64, 128, 256, 512]; +await Promise.all( + linuxSizes.map(async (size) => { + const dest = path.join(iconDir, `${size}x${size}.png`); + if (size === 512) { + await fs.copyFile(pngPath, dest); + return; + } + await sharp(svg).resize(size, size).png().toFile(dest); + }), +); + +console.log('Wrote build/icon.png, build/icon.ico, and build/icons/*.png'); diff --git a/scripts/with-build-version.mjs b/scripts/with-build-version.mjs index 5f27302..cd839d6 100644 --- a/scripts/with-build-version.mjs +++ b/scripts/with-build-version.mjs @@ -38,6 +38,12 @@ function removeStaleDirs() { } } +const builderArgs = process.argv.slice(2); +// `npm run build` / `build:dir` pass --win. Linux `.deb` builds (`--linux`) must +// not stamp a Windows file version onto the package, and must not delete a +// leftover win-unpacked directory. +const buildingWindows = builderArgs.includes('--win'); + const pad = (n) => String(n).padStart(2, '0'); const now = new Date(); const stamp = [ @@ -52,26 +58,31 @@ const stamp = [ // File Version (PE Details) = appVersion.buildDatetime, e.g. 1.0.2.20260722165342 // Product / app version stays package.json "version" (e.g. 1.0.2). const buildVersion = `${pkg.version}.${stamp}`; -console.log(`File version: ${buildVersion}`); +const cliArgs = [...builderArgs]; +if (buildingWindows) { + console.log(`File version: ${buildVersion}`); + cliArgs.push(`-c.buildVersion=${buildVersion}`); +} const MAX_ATTEMPTS = 3; let status = 1; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - removeStaleDirs(); + if (buildingWindows) removeStaleDirs(); const result = spawnSync( process.execPath, - [electronBuilderCli, ...process.argv.slice(2), `-c.buildVersion=${buildVersion}`], + [electronBuilderCli, ...cliArgs], { stdio: 'inherit', cwd: root }, ); status = result.status ?? 1; if (status === 0) break; - // Only the file-lock failure is worth retrying, and it always leaves the - // half-extracted win-unpacked.tmp behind. Anything else (a config error, a - // missing binary) would just fail again more slowly. + // Only the Windows file-lock failure is worth retrying, and it always leaves + // the half-extracted win-unpacked.tmp behind. Linux deb builds never create + // that directory, so a real failure there is not retried. Anything else (a + // config error, a missing binary) would just fail again more slowly. const lockFailure = existsSync(path.join(root, 'release', 'win-unpacked.tmp')); if (!lockFailure) break; From d081aeda38eb1948d36f141fb023d9ca617b0e9c Mon Sep 17 00:00:00 2001 From: Michael Staake Date: Tue, 27 Oct 2026 13:36:37 +0000 Subject: [PATCH 3/4] Set the deb glibc floor and depend on libudev Electron 43's highest glibc symbol is 2.25, and the binary links libudev directly. Ubuntu 26.04's glibc 2.43 still satisfies that dependency. --- README.md | 2 +- package.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dd3a035..f322227 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The Linux package is a separate command. `npm run build` still writes the Window npm run build:deb ``` -Writes `release/Basil--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. Ubuntu 26.04 ships glibc 2.43, which satisfies the package's libc requirement. The only Linux target is this `.deb`. +Writes `release/Basil--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 diff --git a/package.json b/package.json index 191950e..e0a0c00 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,7 @@ "packageCategory": "editors", "priority": "optional", "depends": [ - "libc6 (>= 2.35)", + "libc6 (>= 2.25)", "libgtk-3-0t64 | libgtk-3-0", "libglib2.0-0t64 | libglib2.0-0", "libatk1.0-0t64 | libatk1.0-0", @@ -177,6 +177,7 @@ "libxrandr2", "libxss1", "libxtst6", + "libudev1", "libsecret-1-0", "libuuid1", "xdg-utils" From e7f0575ae34fd6024591f164637e84d133a1d44f Mon Sep 17 00:00:00 2001 From: Michael Staake Date: Tue, 27 Oct 2026 13:38:02 +0000 Subject: [PATCH 4/4] Release 26.10.1 with Napa preset and AI off by default Add Napa AI Gateway between LM Studio and xAI/SpaceXSI, and rename the xAI preset label. Document Windows plus the Ubuntu 26.04+ .deb, version 26.10.1, and that AI stays off until the user turns it on. --- .github/workflows/release.yml | 4 ++-- README.md | 9 ++++++--- package-lock.json | 4 ++-- package.json | 2 +- shared/config.test.ts | 33 ++++++++++++++++++++++++++++++++- shared/config.ts | 14 ++++++++++---- src/components/AiSidebar.tsx | 2 +- src/components/SettingsPane.tsx | 7 +++++-- 8 files changed, 59 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63129ee..233aabd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,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" @@ -94,7 +94,7 @@ jobs: 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.7.1 or v26.7.1)." >&2 + 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 diff --git a/README.md b/README.md index f322227..b14dfe9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [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 @@ -29,7 +29,7 @@ sudo apt install ./Basil--amd64.deb - 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. The Ubuntu `.deb` adds an applications-menu entry and Open With for common text files. Alternately, you may build it from source. @@ -120,9 +120,12 @@ System light and dark mode on Linux follows Electron's `nativeTheme` (the GTK / | 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. diff --git a/package-lock.json b/package-lock.json index e988cf1..bc97eac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "basil", - "version": "1.0.6", + "version": "26.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "basil", - "version": "1.0.6", + "version": "26.10.1", "license": "GPL-3.0-or-later", "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/package.json b/package.json index e0a0c00..a1ec238 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "basil", - "version": "26.7.1", + "version": "26.10.1", "description": "Basil Text Editor", "author": "Michael Staake", "license": "GPL-3.0-or-later", diff --git a/shared/config.test.ts b/shared/config.test.ts index 83d3e9e..5c0864f 100644 --- a/shared/config.test.ts +++ b/shared/config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + AI_PRESETS, DEFAULT_AI, DEFAULT_PREFERENCES, DEFAULT_TOOLBAR, @@ -106,11 +107,26 @@ describe('resolveAiBaseUrl', () => { expect(resolveAiBaseUrl({ ...DEFAULT_AI, preset: 'lmstudio' })).toBe( 'http://localhost:1234/v1', ); + expect(resolveAiBaseUrl({ ...DEFAULT_AI, preset: 'napa' })).toBe( + 'https://gateway-api.napa-ai.com/v1', + ); expect(resolveAiBaseUrl({ ...DEFAULT_AI, preset: 'xai' })).toBe( 'https://api.x.ai/v1', ); }); + it('lists built-in presets as LM Studio, Napa AI Gateway, then xAI/SpaceXSI', () => { + expect(Object.keys(AI_PRESETS)).toEqual(['lmstudio', 'napa', 'xai']); + expect(AI_PRESETS.napa).toEqual({ + label: 'Napa AI Gateway', + baseUrl: 'https://gateway-api.napa-ai.com/v1', + model: 'grok-3', + }); + expect(AI_PRESETS.napa).not.toHaveProperty('apiKey'); + expect(AI_PRESETS.xai.label).toBe('xAI/SpaceXSI'); + expect(AI_PRESETS.lmstudio.label).toBe('LM Studio'); + }); + it('uses the configured url for the custom preset', () => { expect( resolveAiBaseUrl({ ...DEFAULT_AI, preset: 'custom', baseUrl: 'https://x/v1' }), @@ -129,9 +145,11 @@ describe('isAiConfigured', () => { expect(isAiConfigured({ ...DEFAULT_AI, preset: 'lmstudio', apiKey: '' })).toBe(true); }); - it('requires a key for xai', () => { + it('requires a key for xai and napa', () => { expect(isAiConfigured({ ...DEFAULT_AI, preset: 'xai', apiKey: '' })).toBe(false); expect(isAiConfigured({ ...DEFAULT_AI, preset: 'xai', apiKey: 'k' })).toBe(true); + expect(isAiConfigured({ ...DEFAULT_AI, preset: 'napa', apiKey: '' })).toBe(false); + expect(isAiConfigured({ ...DEFAULT_AI, preset: 'napa', apiKey: 'k' })).toBe(true); }); it('requires a key for a remote custom endpoint but not a local one', () => { @@ -168,6 +186,19 @@ describe('normalizeAiConfig', () => { expect(normalizeAiConfig({ preset: 'bogus' }).preset).toBe(DEFAULT_AI.preset); }); + it('keeps the napa preset', () => { + const result = normalizeAiConfig({ + preset: 'napa', + baseUrl: AI_PRESETS.napa.baseUrl, + apiKey: '', + model: AI_PRESETS.napa.model, + systemPrompt: DEFAULT_AI.systemPrompt, + }); + expect(result.preset).toBe('napa'); + expect(result.baseUrl).toBe('https://gateway-api.napa-ai.com/v1'); + expect(result.apiKey).toBe(''); + }); + it('keeps valid values', () => { const config = { preset: 'custom', diff --git a/shared/config.ts b/shared/config.ts index 3af31a8..143ef0f 100644 --- a/shared/config.ts +++ b/shared/config.ts @@ -55,7 +55,7 @@ export type Preferences = { }; }; -export type AiPreset = 'lmstudio' | 'xai' | 'custom'; +export type AiPreset = 'lmstudio' | 'napa' | 'xai' | 'custom'; export type AiConfig = { preset: AiPreset; @@ -153,8 +153,14 @@ export const AI_PRESETS: Record< baseUrl: 'http://localhost:1234/v1', model: 'local-model', }, + napa: { + label: 'Napa AI Gateway', + baseUrl: 'https://gateway-api.napa-ai.com/v1', + // No single catalog model; grok-3 is an editable chat default. API key stays empty. + model: 'grok-3', + }, xai: { - label: 'xAI', + label: 'xAI/SpaceXSI', baseUrl: 'https://api.x.ai/v1', model: 'grok-3', }, @@ -275,7 +281,7 @@ function normalizeFontFamily(value: unknown): EditorFontId { return DEFAULT_PREFERENCES.fontFamily; } -const VALID_PRESETS = new Set(['lmstudio', 'xai', 'custom']); +const VALID_PRESETS = new Set(['lmstudio', 'napa', 'xai', 'custom']); /** * Validate an ai.json that may have been hand-edited. Without this an unknown @@ -312,7 +318,7 @@ export function isAiConfigured(config: AiConfig): boolean { const baseUrl = resolveAiBaseUrl(config).trim(); if (!baseUrl || !config?.model?.trim()) return false; const apiKey = config.apiKey?.trim() ?? ''; - if (config.preset === 'xai' && !apiKey) return false; + if ((config.preset === 'xai' || config.preset === 'napa') && !apiKey) return false; if (config.preset === 'custom') { const local = /localhost|127\.0\.0\.1/i.test(baseUrl); if (!local && !apiKey) return false; diff --git a/src/components/AiSidebar.tsx b/src/components/AiSidebar.tsx index 7b076bb..484b45b 100644 --- a/src/components/AiSidebar.tsx +++ b/src/components/AiSidebar.tsx @@ -267,7 +267,7 @@ export function AiSidebar({ enabled, tabs, activeTab }: Props) { ); diff --git a/src/components/SettingsPane.tsx b/src/components/SettingsPane.tsx index 2058b88..415e3aa 100644 --- a/src/components/SettingsPane.tsx +++ b/src/components/SettingsPane.tsx @@ -306,8 +306,11 @@ export function SettingsPane({ preferences, aiConfig, onSave }: Props) { disabled={!prefs.aiEnabled} onChange={(e) => applyPreset(e.target.value as AiPreset)} > - - + {(Object.keys(AI_PRESETS) as Array).map((id) => ( + + ))}