mirror of
https://github.com/likelovewant/ollama-for-amd.git
synced 2025-12-21 22:33:56 +00:00
app: add code for macOS and Windows apps under 'app' (#12933)
* app: add code for macOS and Windows apps under 'app' * app: add readme * app: windows and linux only for now * ci: fix ui CI validation --------- Co-authored-by: jmorganca <jmorganca@gmail.com>
This commit is contained in:
528
app/cmd/app/webview.go
Normal file
528
app/cmd/app/webview.go
Normal file
@@ -0,0 +1,528 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
// #include "menu.h"
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ollama/ollama/app/dialog"
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
)
|
||||
|
||||
type Webview struct {
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// Run initializes the webview and starts its event loop.
|
||||
// Note: this must be called from the primary app thread
|
||||
// This returns the OS native window handle to the caller
|
||||
func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
var url string
|
||||
if devMode {
|
||||
// In development mode, use the local dev server
|
||||
url = fmt.Sprintf("http://localhost:5173%s", path)
|
||||
} else {
|
||||
url = fmt.Sprintf("http://127.0.0.1:%d%s", w.port, path)
|
||||
}
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
|
||||
if w.webview == nil {
|
||||
// Note: turning on debug on macos throws errors but is marginally functional for debugging
|
||||
// TODO (jmorganca): we should pre-create the window and then provide it here to
|
||||
// webview so we can hide it from the start and make other modifications
|
||||
wv := webview.New(debug)
|
||||
// start the window hidden
|
||||
hideWindow(wv.Window())
|
||||
wv.SetTitle("Ollama")
|
||||
|
||||
// TODO (jmorganca): this isn't working yet since it needs to be set
|
||||
// on the first page load, ideally in an interstitial page like `/token`
|
||||
// that exists only to set the cookie and redirect to /
|
||||
// wv.Init(fmt.Sprintf(`document.cookie = "token=%s; path=/"`, w.token))
|
||||
init := `
|
||||
// Disable reload
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent back/forward navigation
|
||||
window.addEventListener('popstate', function(e) {
|
||||
e.preventDefault();
|
||||
history.pushState(null, '', window.location.pathname);
|
||||
return false;
|
||||
});
|
||||
|
||||
// Clear history on load
|
||||
window.addEventListener('load', function() {
|
||||
history.pushState(null, '', window.location.pathname);
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
});
|
||||
|
||||
// Set token cookie
|
||||
document.cookie = "token=` + w.token + `; path=/";
|
||||
`
|
||||
// Windows-specific scrollbar styling
|
||||
if runtime.GOOS == "windows" {
|
||||
init += `
|
||||
// Fix scrollbar styling for Edge WebView2 on Windows only
|
||||
function updateScrollbarStyles() {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const existingStyle = document.getElementById('scrollbar-style');
|
||||
if (existingStyle) existingStyle.remove();
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'scrollbar-style';
|
||||
|
||||
if (isDark) {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-thumb { background: #404040 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #505050 !important; }
|
||||
::-webkit-scrollbar-corner { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
} else {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
|
||||
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
}
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
window.addEventListener('load', updateScrollbarStyles);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateScrollbarStyles);
|
||||
`
|
||||
}
|
||||
// on windows make ctrl+n open new chat
|
||||
// TODO (jmorganca): later we should use proper accelerators
|
||||
// once we introduce a native menu for the window
|
||||
// this is only used on windows since macOS uses the proper accelerators
|
||||
if runtime.GOOS == "windows" {
|
||||
init += `
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
// Use the existing navigation method
|
||||
history.pushState({}, '', '/c/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
`
|
||||
}
|
||||
|
||||
init += `
|
||||
window.OLLAMA_WEBSEARCH = true;
|
||||
`
|
||||
|
||||
wv.Init(init)
|
||||
|
||||
// Add keyboard handler for zoom
|
||||
wv.Init(`
|
||||
window.addEventListener('keydown', function(e) {
|
||||
// CMD/Ctrl + Plus/Equals (zoom in)
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === '+' || e.key === '=')) {
|
||||
e.preventDefault();
|
||||
window.zoomIn && window.zoomIn();
|
||||
return false;
|
||||
}
|
||||
|
||||
// CMD/Ctrl + Minus (zoom out)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '-') {
|
||||
e.preventDefault();
|
||||
window.zoomOut && window.zoomOut();
|
||||
return false;
|
||||
}
|
||||
|
||||
// CMD/Ctrl + 0 (reset zoom)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '0') {
|
||||
e.preventDefault();
|
||||
window.zoomReset && window.zoomReset();
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
`)
|
||||
|
||||
wv.Bind("zoomIn", func() {
|
||||
current := wv.GetZoom()
|
||||
wv.SetZoom(current + 0.1)
|
||||
})
|
||||
|
||||
wv.Bind("zoomOut", func() {
|
||||
current := wv.GetZoom()
|
||||
wv.SetZoom(current - 0.1)
|
||||
})
|
||||
|
||||
wv.Bind("zoomReset", func() {
|
||||
wv.SetZoom(1.0)
|
||||
})
|
||||
|
||||
wv.Bind("ready", func() {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("close", func() {
|
||||
hideWindow(wv.Window())
|
||||
})
|
||||
|
||||
// Webviews do not allow access to the file system by default, so we need to
|
||||
// bind file system operations here
|
||||
wv.Bind("selectModelsDirectory", func() {
|
||||
go func() {
|
||||
// Helper function to call the JavaScript callback with data or null
|
||||
callCallback := func(data interface{}) {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
wv.Dispatch(func() {
|
||||
wv.Eval(fmt.Sprintf("window.__selectModelsDirectoryCallback && window.__selectModelsDirectoryCallback(%s)", dataJSON))
|
||||
})
|
||||
}
|
||||
|
||||
directory, err := dialog.Directory().Title("Select Model Directory").ShowHidden(true).Browse()
|
||||
if err != nil {
|
||||
slog.Debug("Directory selection cancelled or failed", "error", err)
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
slog.Debug("Directory selected", "path", directory)
|
||||
callCallback(directory)
|
||||
}()
|
||||
})
|
||||
|
||||
// Bind selectFiles function for selecting multiple files at once
|
||||
wv.Bind("selectFiles", func() {
|
||||
go func() {
|
||||
// Helper function to call the JavaScript callback with data or null
|
||||
callCallback := func(data interface{}) {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
wv.Dispatch(func() {
|
||||
wv.Eval(fmt.Sprintf("window.__selectFilesCallback && window.__selectFilesCallback(%s)", dataJSON))
|
||||
})
|
||||
}
|
||||
|
||||
// Define allowed extensions for native dialog filtering
|
||||
textExts := []string{
|
||||
"pdf", "docx", "txt", "md", "csv", "json", "xml", "html", "htm",
|
||||
"js", "jsx", "ts", "tsx", "py", "java", "cpp", "c", "cc", "h", "cs", "php", "rb",
|
||||
"go", "rs", "swift", "kt", "scala", "sh", "bat", "yaml", "yml", "toml", "ini",
|
||||
"cfg", "conf", "log", "rtf",
|
||||
}
|
||||
imageExts := []string{"png", "jpg", "jpeg"}
|
||||
allowedExts := append(textExts, imageExts...)
|
||||
|
||||
// Use native multiple file selection with extension filtering
|
||||
filenames, err := dialog.File().
|
||||
Filter("Supported Files", allowedExts...).
|
||||
Title("Select Files").
|
||||
LoadMultiple()
|
||||
if err != nil {
|
||||
slog.Debug("Multiple file selection cancelled or failed", "error", err)
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if len(filenames) == 0 {
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
|
||||
var files []map[string]string
|
||||
maxFileSize := int64(10 * 1024 * 1024) // 10MB
|
||||
|
||||
for _, filename := range filenames {
|
||||
// Check file extension (double-check after native dialog filtering)
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
validExt := false
|
||||
for _, allowedExt := range allowedExts {
|
||||
if ext == allowedExt {
|
||||
validExt = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validExt {
|
||||
slog.Warn("file extension not allowed, skipping", "filename", filepath.Base(filename), "extension", ext)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check file size before reading (pre-filter large files)
|
||||
fileStat, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
slog.Error("failed to get file info", "error", err, "filename", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
if fileStat.Size() > maxFileSize {
|
||||
slog.Warn("file too large, skipping", "filename", filepath.Base(filename), "size", fileStat.Size())
|
||||
continue
|
||||
}
|
||||
|
||||
fileBytes, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
slog.Error("failed to read file", "error", err, "filename", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(fileBytes)
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(fileBytes))
|
||||
|
||||
fileResult := map[string]string{
|
||||
"filename": filepath.Base(filename),
|
||||
"path": filename,
|
||||
"dataURL": dataURL,
|
||||
}
|
||||
|
||||
files = append(files, fileResult)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
callCallback(nil)
|
||||
} else {
|
||||
callCallback(files)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
wv.Bind("drag", func() {
|
||||
wv.Dispatch(func() {
|
||||
drag(wv.Window())
|
||||
})
|
||||
})
|
||||
|
||||
wv.Bind("doubleClick", func() {
|
||||
wv.Dispatch(func() {
|
||||
doubleClick(wv.Window())
|
||||
})
|
||||
})
|
||||
|
||||
// Add binding for working directory selection
|
||||
wv.Bind("selectWorkingDirectory", func() {
|
||||
go func() {
|
||||
// Helper function to call the JavaScript callback with data or null
|
||||
callCallback := func(data interface{}) {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
wv.Dispatch(func() {
|
||||
wv.Eval(fmt.Sprintf("window.__selectWorkingDirectoryCallback && window.__selectWorkingDirectoryCallback(%s)", dataJSON))
|
||||
})
|
||||
}
|
||||
|
||||
directory, err := dialog.Directory().Title("Select Working Directory").ShowHidden(true).Browse()
|
||||
if err != nil {
|
||||
slog.Debug("Directory selection cancelled or failed", "error", err)
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
slog.Debug("Directory selected", "path", directory)
|
||||
callCallback(directory)
|
||||
}()
|
||||
})
|
||||
|
||||
wv.Bind("setContextMenuItems", func(items []map[string]interface{}) error {
|
||||
menuMutex.Lock()
|
||||
defer menuMutex.Unlock()
|
||||
|
||||
if len(menuItems) > 0 {
|
||||
pinner.Unpin()
|
||||
}
|
||||
|
||||
menuItems = nil
|
||||
for _, item := range items {
|
||||
menuItem := C.menuItem{
|
||||
label: C.CString(item["label"].(string)),
|
||||
enabled: 0,
|
||||
separator: 0,
|
||||
}
|
||||
|
||||
if item["enabled"] != nil {
|
||||
menuItem.enabled = 1
|
||||
}
|
||||
|
||||
if item["separator"] != nil {
|
||||
menuItem.separator = 1
|
||||
}
|
||||
menuItems = append(menuItems, menuItem)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Debounce resize events
|
||||
var resizeTimer *time.Timer
|
||||
var resizeMutex sync.Mutex
|
||||
|
||||
wv.Bind("resize", func(width, height int) {
|
||||
if w.Store != nil {
|
||||
resizeMutex.Lock()
|
||||
if resizeTimer != nil {
|
||||
resizeTimer.Stop()
|
||||
}
|
||||
resizeTimer = time.AfterFunc(100*time.Millisecond, func() {
|
||||
err := w.Store.SetWindowSize(width, height)
|
||||
if err != nil {
|
||||
slog.Error("failed to set window size", "error", err)
|
||||
}
|
||||
})
|
||||
resizeMutex.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
// On Darwin, we can't have 2 threads both running global event loops
|
||||
// but on Windows, the event loops are tied to the window, so we're
|
||||
// able to run in both the tray and webview
|
||||
if runtime.GOOS != "darwin" {
|
||||
slog.Debug("starting webview event loop")
|
||||
go func() {
|
||||
wv.Run()
|
||||
slog.Debug("webview event loop exited")
|
||||
}()
|
||||
}
|
||||
|
||||
if w.Store != nil {
|
||||
width, height, err := w.Store.WindowSize()
|
||||
if err != nil {
|
||||
slog.Error("failed to get window size", "error", err)
|
||||
}
|
||||
if width > 0 && height > 0 {
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
} else {
|
||||
wv.SetSize(800, 600, webview.HintNone)
|
||||
}
|
||||
}
|
||||
wv.SetSize(800, 600, webview.HintMin)
|
||||
|
||||
w.webview = wv
|
||||
w.webview.Navigate(url)
|
||||
} else {
|
||||
w.webview.Eval(fmt.Sprintf(`
|
||||
history.pushState({}, '', '%s');
|
||||
`, path))
|
||||
showWindow(w.webview.Window())
|
||||
}
|
||||
|
||||
return w.webview.Window()
|
||||
}
|
||||
|
||||
func (w *Webview) Terminate() {
|
||||
w.mutex.Lock()
|
||||
if w.webview == nil {
|
||||
w.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
wv := w.webview
|
||||
w.webview = nil
|
||||
w.mutex.Unlock()
|
||||
wv.Terminate()
|
||||
wv.Destroy()
|
||||
}
|
||||
|
||||
func (w *Webview) IsRunning() bool {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
return w.webview != nil
|
||||
}
|
||||
|
||||
var (
|
||||
menuItems []C.menuItem
|
||||
menuMutex sync.RWMutex
|
||||
pinner runtime.Pinner
|
||||
)
|
||||
|
||||
//export menu_get_item_count
|
||||
func menu_get_item_count() C.int {
|
||||
menuMutex.RLock()
|
||||
defer menuMutex.RUnlock()
|
||||
return C.int(len(menuItems))
|
||||
}
|
||||
|
||||
//export menu_get_items
|
||||
func menu_get_items() unsafe.Pointer {
|
||||
menuMutex.RLock()
|
||||
defer menuMutex.RUnlock()
|
||||
|
||||
if len(menuItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return pointer to the slice data
|
||||
pinner.Pin(&menuItems[0])
|
||||
return unsafe.Pointer(&menuItems[0])
|
||||
}
|
||||
|
||||
//export menu_handle_selection
|
||||
func menu_handle_selection(item *C.char) {
|
||||
wv.webview.Eval(fmt.Sprintf("window.handleContextMenuResult('%s')", C.GoString(item)))
|
||||
}
|
||||
Reference in New Issue
Block a user