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
12 changes: 12 additions & 0 deletions .changeset/windows-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"think-app": minor
---

Add Windows support

- Windows NSIS installer with desktop/start menu shortcuts
- Native messaging host registration via `reg.exe` (removed `winreg` dependency)
- Cross-platform Ollama download using native `https` module
- Graceful FTS5 fallback when module unavailable
- Binary mode for salt file to fix Windows password unlock
- Cross-platform build scripts for backend and native stub
85 changes: 84 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
with:
generate_release_notes: true
draft: true
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down Expand Up @@ -141,10 +142,91 @@ jobs:
path: app/release/*.dmg
retention-days: 1

build-windows:
name: Build Windows App
runs-on: windows-latest
needs: create-release
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10

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

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install Poetry
run: pip install poetry==1.8.0

- name: Configure Poetry
run: poetry config virtualenvs.in-project true
working-directory: backend

- name: Install pnpm dependencies
run: pnpm install --frozen-lockfile

- name: Install backend dependencies
working-directory: backend
run: poetry install

- name: Download MSVC-compiled sqlite-vec
shell: pwsh
run: |
# Download MSVC-compiled vec0.dll from sqlite-vec releases
$vec0Url = "https://github.com/asg017/sqlite-vec/releases/download/v0.1.6/sqlite-vec-0.1.6-loadable-windows-x86_64.tar.gz"
$tarPath = "$env:RUNNER_TEMP\sqlite-vec.tar.gz"
$extractPath = "$env:RUNNER_TEMP\sqlite-vec"

Invoke-WebRequest -Uri $vec0Url -OutFile $tarPath
New-Item -ItemType Directory -Path $extractPath -Force
tar -xzf $tarPath -C $extractPath

# Find where sqlite_vec package is installed and copy vec0.dll there
$sitePackages = poetry run python -c "import site; print(site.getsitepackages()[0])"
$sqliteVecDir = Join-Path $sitePackages "sqlite_vec"

if (Test-Path $sqliteVecDir) {
Copy-Item "$extractPath\vec0.dll" -Destination $sqliteVecDir -Force
Write-Host "Copied vec0.dll to $sqliteVecDir"
} else {
Write-Host "Warning: sqlite_vec directory not found at $sqliteVecDir"
}
working-directory: backend

- name: Build backend
run: pnpm build:backend

- name: Build native stub
run: pnpm build:stub

- name: Build app (frontend)
run: pnpm --filter think-app build

- name: Build Electron app (NSIS installer)
run: pnpm --filter think-app electron:build

- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: electron-app-windows
path: app/release/*.exe
retention-days: 1

upload-release:
name: Upload Release Artifacts
runs-on: ubuntu-latest
needs: [create-release, build-extension, build-macos]
needs: [create-release, build-extension, build-macos, build-windows]
permissions:
contents: write
steps:
Expand All @@ -162,6 +244,7 @@ jobs:
files: |
artifacts/chrome-extension/*.zip
artifacts/electron-app-macos/*.dmg
artifacts/electron-app-windows/*.exe
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Binary file added app/build/icon.ico
Binary file not shown.
100 changes: 30 additions & 70 deletions app/electron/install-native-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');

// Extension IDs - update these when publishing to browser stores
// During development, Chrome assigns a dynamic ID based on the extension path
Expand Down Expand Up @@ -128,76 +129,40 @@ function installManifest(directory, manifest) {
}

/**
* Install Windows registry entries.
* Install Windows registry entries using reg.exe (no external dependencies).
*/
function installWindowsRegistry(stubPath, extensionIds) {
// Windows requires registry entries instead of manifest files
// This uses the 'winreg' module - install with: npm install winreg
// Windows requires registry entries that point to manifest files
const manifestDir = path.dirname(stubPath);

let Registry;
try {
Registry = require('winreg');
} catch (e) {
console.error('[Native Host] winreg module not found. Install with: npm install winreg');
console.error('[Native Host] Skipping Windows registry installation');
return;
}
// Chrome/Edge manifest
const chromeManifestPath = path.join(manifestDir, `${NATIVE_HOST_NAME}.json`);
const chromeManifest = createChromeManifest(stubPath, extensionIds);
fs.writeFileSync(chromeManifestPath, JSON.stringify(chromeManifest, null, 2));

// Firefox manifest
const firefoxManifestPath = path.join(manifestDir, `${NATIVE_HOST_NAME}.firefox.json`);
const firefoxManifest = createFirefoxManifest(stubPath);
fs.writeFileSync(firefoxManifestPath, JSON.stringify(firefoxManifest, null, 2));

const browsers = [
{ name: 'Chrome', key: '\\Software\\Google\\Chrome\\NativeMessagingHosts\\' + NATIVE_HOST_NAME },
{ name: 'Edge', key: '\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\' + NATIVE_HOST_NAME },
{ name: 'Chrome', key: `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`, manifest: chromeManifestPath },
{ name: 'Edge', key: `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`, manifest: chromeManifestPath },
{ name: 'Firefox', key: `HKCU\\Software\\Mozilla\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`, manifest: firefoxManifestPath },
];

for (const browser of browsers) {
try {
const regKey = new Registry({
hive: Registry.HKCU,
key: browser.key,
});

// Create manifest file in app directory
const manifestDir = path.dirname(stubPath);
const manifestPath = path.join(manifestDir, `${NATIVE_HOST_NAME}.json`);

const manifest = createChromeManifest(stubPath, extensionIds);
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));

// Point registry to manifest file
regKey.set('', Registry.REG_SZ, manifestPath, (err) => {
if (err) {
console.error(`[Native Host] Failed to set registry for ${browser.name}:`, err);
} else {
console.log(`[Native Host] Installed registry entry for ${browser.name}`);
}
// Use reg.exe to add registry key (no external dependencies needed)
execSync(`reg add "${browser.key}" /ve /t REG_SZ /d "${browser.manifest}" /f`, {
stdio: 'pipe',
windowsHide: true,
});
console.log(`[Native Host] Installed registry entry for ${browser.name}`);
} catch (error) {
console.error(`[Native Host] Failed to install registry for ${browser.name}:`, error.message);
}
}

// Firefox on Windows also uses registry
try {
const firefoxKey = new Registry({
hive: Registry.HKCU,
key: '\\Software\\Mozilla\\NativeMessagingHosts\\' + NATIVE_HOST_NAME,
});

const manifestDir = path.dirname(stubPath);
const manifestPath = path.join(manifestDir, `${NATIVE_HOST_NAME}.firefox.json`);

const manifest = createFirefoxManifest(stubPath);
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));

firefoxKey.set('', Registry.REG_SZ, manifestPath, (err) => {
if (err) {
console.error('[Native Host] Failed to set registry for Firefox:', err);
} else {
console.log('[Native Host] Installed registry entry for Firefox');
}
});
} catch (error) {
console.error('[Native Host] Failed to install registry for Firefox:', error.message);
}
}

/**
Expand Down Expand Up @@ -272,31 +237,26 @@ function installNativeHost(resourcesPath, extensionIds = CHROME_EXTENSION_IDS, i

/**
* Uninstall native messaging host manifests.
* @param {string} resourcesPath - Path to Electron app resources directory
*/
function uninstallNativeHost(resourcesPath) {
function uninstallNativeHost() {
console.log('[Native Host] Uninstalling native messaging host manifests...');

if (process.platform === 'win32') {
// Remove Windows registry entries
const Registry = require('winreg');

// Remove Windows registry entries using reg.exe
const keys = [
'\\Software\\Google\\Chrome\\NativeMessagingHosts\\' + NATIVE_HOST_NAME,
'\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\' + NATIVE_HOST_NAME,
'\\Software\\Mozilla\\NativeMessagingHosts\\' + NATIVE_HOST_NAME,
`HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`,
`HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`,
`HKCU\\Software\\Mozilla\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`,
];

for (const key of keys) {
try {
const regKey = new Registry({ hive: Registry.HKCU, key });
regKey.destroy((err) => {
if (err && err.code !== 2) { // Ignore "key not found" errors
console.error(`[Native Host] Failed to remove registry key ${key}:`, err);
}
execSync(`reg delete "${key}" /f`, {
stdio: 'pipe',
windowsHide: true,
});
} catch (error) {
// Ignore errors
// Ignore errors (key might not exist)
}
}

Expand Down
79 changes: 69 additions & 10 deletions app/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,58 @@ const fs = require('fs');
const https = require('https');
const { installNativeHost } = require('./install-native-host');

/**
* Download a file with progress reporting.
* Uses native https module to avoid blocking the main process.
*/
function downloadFile(url, destPath, onProgress) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destPath);

const makeRequest = (urlString) => {
https.get(urlString, (response) => {
// Handle redirects
if (response.statusCode === 301 || response.statusCode === 302) {
makeRequest(response.headers.location);
return;
}

if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
return;
}

const totalSize = parseInt(response.headers['content-length'], 10);
let downloadedSize = 0;

response.on('data', (chunk) => {
downloadedSize += chunk.length;
if (onProgress && totalSize) {
onProgress(Math.round((downloadedSize / totalSize) * 100));
}
});

response.pipe(file);

file.on('finish', () => {
file.close();
resolve();
});

file.on('error', (err) => {
fs.unlink(destPath, () => {}); // Delete partial file
reject(err);
});
}).on('error', (err) => {
fs.unlink(destPath, () => {}); // Delete partial file
reject(err);
});
};

makeRequest(url);
});
}

let mainWindow;
let pythonProcess;
let backendReady = false;
Expand Down Expand Up @@ -277,12 +329,14 @@ ipcMain.handle('download-ollama', async (_event) => {
const zipPath = path.join(tempDir, 'Ollama-darwin.zip');
const downloadUrl = 'https://ollama.com/download/Ollama-darwin.zip';

mainWindow.webContents.send('ollama-download-progress', { progress: 10, stage: 'downloading' });
mainWindow.webContents.send('ollama-download-progress', { progress: 5, stage: 'downloading' });

// Use curl for reliable redirect handling
execSync(`curl -L "${downloadUrl}" -o "${zipPath}"`, { stdio: 'pipe' });

mainWindow.webContents.send('ollama-download-progress', { progress: 80, stage: 'downloading' });
// Async download with progress reporting
await downloadFile(downloadUrl, zipPath, (percent) => {
// Map 0-100% download progress to 5-80% overall progress
const overallProgress = 5 + Math.round(percent * 0.75);
mainWindow.webContents.send('ollama-download-progress', { progress: overallProgress, stage: 'downloading' });
});

mainWindow.webContents.send('ollama-download-progress', { progress: 100, stage: 'installing' });

Expand Down Expand Up @@ -315,17 +369,22 @@ ipcMain.handle('download-ollama', async (_event) => {
const exePath = path.join(tempDir, 'OllamaSetup.exe');
const downloadUrl = 'https://ollama.com/download/OllamaSetup.exe';

mainWindow.webContents.send('ollama-download-progress', { progress: 10, stage: 'downloading' });
mainWindow.webContents.send('ollama-download-progress', { progress: 5, stage: 'downloading' });

// Use curl for reliable redirect handling
execSync(`curl -L "${downloadUrl}" -o "${exePath}"`, { stdio: 'pipe' });
// Async download with progress reporting
await downloadFile(downloadUrl, exePath, (percent) => {
// Map 0-100% download progress to 5-80% overall progress
const overallProgress = 5 + Math.round(percent * 0.75);
mainWindow.webContents.send('ollama-download-progress', { progress: overallProgress, stage: 'downloading' });
});

mainWindow.webContents.send('ollama-download-progress', { progress: 80, stage: 'downloading' });
mainWindow.webContents.send('ollama-download-progress', { progress: 100, stage: 'installing' });
mainWindow.webContents.send('ollama-download-progress', { progress: 85, stage: 'installing' });

// Run silent install
execSync(`"${exePath}" /VERYSILENT /NORESTART`, { stdio: 'ignore' });

mainWindow.webContents.send('ollama-download-progress', { progress: 100, stage: 'starting' });

// Launch Ollama
spawn(ollamaPath, ['serve'], { detached: true, stdio: 'ignore' });

Expand Down
Loading