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
16 changes: 13 additions & 3 deletions app/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,12 @@ type Driver struct {
hasOpenedPath bool
openedWith [2]string
hasOpenedWith bool
releases []func()
closed bool
appearanceOpen bool
// openedInBrowser is the file OpenPathInBrowser was last given.
openedInBrowser string
hasOpenedInBrowser bool
releases []func()
closed bool
appearanceOpen bool

system driverSystem
openFilesResponses [driverOpenFilesQueueSize]driverOpenFilesResponse
Expand Down Expand Up @@ -979,6 +982,13 @@ func (driver *Driver) LastOpenedWith() (executable, path string, ok bool) {
return driver.openedWith[0], driver.openedWith[1], driver.hasOpenedWith
}

// LastOpenedInBrowser returns the most recent file passed to OpenPathInBrowser
// while this driver was active. Driver-backed runs record the request and start
// no browser.
func (driver *Driver) LastOpenedInBrowser() (string, bool) {
return driver.openedInBrowser, driver.hasOpenedInBrowser
}

// PrimitiveKind says what a Primitive is.
type PrimitiveKind uint8

Expand Down
10 changes: 10 additions & 0 deletions app/driver_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,16 @@ func driverOpenWith(executable, path string) (error, bool) {
return nil, true
}

func driverOpenInBrowser(path string) (error, bool) {
system := activeDriverSystem.Load()
if system == nil {
return nil, false
}
system.driver.openedInBrowser = path
system.driver.hasOpenedInBrowser = true
return nil, true
}

func driverOpenFiles(options OpenFileOptions) ([]string, error, bool) {
system := activeDriverSystem.Load()
if system == nil {
Expand Down
1 change: 1 addition & 0 deletions app/driver_system_unsupported.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func driverRevealPath(string) (error, bool) { return nil, false }
func driverOpenWith(string, string) (error, bool) {
return nil, false
}
func driverOpenInBrowser(string) (error, bool) { return nil, false }

func driverOpenFiles(OpenFileOptions) ([]string, error, bool) {
return nil, nil, false
Expand Down
53 changes: 53 additions & 0 deletions app/open_in_browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package app

import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/vivaui/viva/internal/platform"
)

// ErrNoBrowser is returned by OpenPathInBrowser when the system has no default
// web browser to give the file to.
var ErrNoBrowser = errors.New("app: no default web browser")

// OpenPathInBrowser opens a local file in the user's default web browser, the
// application the system opens http and https links with, whatever application
// the file's own type is associated with. It is for pages an application wrote
// or previews: an .html file is often associated with an editor, where OpenPath
// would open it. The browser is given the file's URL, so it shows the file and
// does not run it. The path must be absolute and name a regular file, not a
// link or a directory. A system with no default browser returns ErrNoBrowser;
// one where a browser cannot be started, as on the Web target, ErrUnsupported.
// Driver-backed runs record the request without starting a browser. A native
// call can wait for the browser to take the file, so callers on an interactive
// window thread should run it away from that thread.
func OpenPathInBrowser(path string) error {
if strings.IndexByte(path, 0) >= 0 {
return fmt.Errorf("app: path contains NUL")
}
if !filepath.IsAbs(path) {
return fmt.Errorf("app: %q is not an absolute path", path)
}
path = filepath.Clean(path)
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("app: %q is not a regular file", path)
}
if err, handled := driverOpenInBrowser(path); handled {
return err
}
if err := nativeOpenInBrowser(path); err != nil {
if errors.Is(err, platform.ErrNoBrowser) {
return ErrNoBrowser
}
return err
}
return nil
}
62 changes: 62 additions & 0 deletions app/open_in_browser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//go:build darwin || windows || linux

package app

import (
"os"
"path/filepath"
"testing"
)

func TestOpenPathInBrowserValidatesBeforeAskingTheSystem(t *testing.T) {
dir := t.TempDir()
page := filepath.Join(dir, "page.html")
if err := os.WriteFile(page, []byte("<p>x</p>"), 0o644); err != nil {
t.Fatal(err)
}
for _, path := range []string{"relative.html", filepath.Join(dir, "missing.html"), dir, page + "\x00"} {
if err := OpenPathInBrowser(path); err == nil {
t.Errorf("%q was accepted", path)
}
}
link := filepath.Join(dir, "link.html")
if err := os.Symlink(page, link); err == nil {
if err := OpenPathInBrowser(link); err == nil {
t.Error("a symbolic link was accepted")
}
}
}

// TestDriverRecordsOpenPathInBrowserAndStartsNothing pins that the browser
// request is its own record: a page that goes to the browser is neither an
// OpenPath nor an OpenURL, so a test can tell which way the file left.
func TestDriverRecordsOpenPathInBrowserAndStartsNothing(t *testing.T) {
driver, err := NewDriver(Config{}, nil)
if err != nil {
t.Fatal(err)
}
defer driver.Close()
dir := t.TempDir()
page := filepath.Join(dir, "my page.html")
if err := os.WriteFile(page, []byte("<p>x</p>"), 0o644); err != nil {
t.Fatal(err)
}
if err := OpenPathInBrowser(dir); err == nil {
t.Fatal("a directory was accepted")
}
if _, ok := driver.LastOpenedInBrowser(); ok {
t.Fatal("a refused path reached the driver")
}
if err := OpenPathInBrowser(page); err != nil {
t.Fatal(err)
}
if opened, ok := driver.LastOpenedInBrowser(); !ok || opened != page {
t.Fatalf("LastOpenedInBrowser = %q, %t", opened, ok)
}
if _, ok := driver.LastOpenedPath(); ok {
t.Fatal("OpenPathInBrowser was recorded as OpenPath")
}
if _, ok := driver.LastOpenedURL(); ok {
t.Fatal("OpenPathInBrowser was recorded as OpenURL")
}
}
9 changes: 9 additions & 0 deletions app/open_in_browser_unsupported.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows && !darwin && !linux

package app

import "fmt"

func nativeOpenInBrowser(string) error {
return fmt.Errorf("%w: OpenPathInBrowser", ErrUnsupported)
}
2 changes: 2 additions & 0 deletions app/system_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ func nativeOpenWith(executable, path string) error {
return darwinplatform.OpenWith(executable, path)
}

func nativeOpenInBrowser(path string) error { return darwinplatform.OpenInBrowser(path) }

func nativePlaySystemSound(sound SystemSound) error {
return darwinplatform.PlaySystemSound(uint8(sound))
}
Expand Down
4 changes: 4 additions & 0 deletions app/system_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ func nativeOpenWith(executable, path string) error {
return linuxSystemError(linuxplatform.OpenWith(executable, path))
}

func nativeOpenInBrowser(path string) error {
return linuxSystemError(linuxplatform.OpenInBrowser(path))
}

func nativePlaySystemSound(sound SystemSound) error {
return linuxSystemError(linuxplatform.PlaySystemSound(uint8(sound)))
}
Expand Down
2 changes: 2 additions & 0 deletions app/system_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ func nativeRevealPath(path string) error { return windowsplatform.RevealPath(pat
func nativeOpenWith(executable, path string) error {
return windowsplatform.OpenWith(executable, path)
}

func nativeOpenInBrowser(path string) error { return windowsplatform.OpenInBrowser(path) }
func nativePlaySystemSound(sound SystemSound) error {
return windowsplatform.PlaySystemSound(uint8(sound))
}
Expand Down
1 change: 1 addition & 0 deletions docs/system-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ synchronous forms report `ErrAsyncRequired`, `ErrSecretStoreUnavailable` or
| Open directory | `NSOpenPanel` | `IFileOpenDialog` | the desktop portal's `FileChooser` | `ErrUnsupported` |
| File drop (`OnFileDrop`) | AppKit drag destination, with paths | `WM_DROPFILES`, with paths | XDND / GTK drop, with paths | DOM drag events; `gfx.FileDropEvent.Files` carries the browser files and `Paths` is empty |
| Open external URL | `NSWorkspace` | `ShellExecuteW` | installed `xdg-open` or `gio` | `window.open`; popup rejection is `ErrUserGestureRequired` |
| Open a local file in the default browser (`OpenPathInBrowser`) | the application LaunchServices opens `https` links in, through `open -a` | the `http` protocol's command from `AssocQueryStringW`, started with the file's `file:` URL and no shell | the desktop entry `xdg-settings get default-web-browser` names (`xdg-mime` for `x-scheme-handler/https` without it), its `Exec` line run with the file's URL; a terminal browser is `ErrUnsupported` | `ErrUnsupported` |
| System sound | AppKit system sounds | `MessageBeep` | `canberra-gtk-play` or X11 bell | `ErrUnsupported` |
| Notification | UserNotifications; app bundle ID and permission required | shell notification icon; running window required | the desktop's `org.freedesktop.Notifications` service, under the executable's name; installed `notify-send` without one; no click callback | only when Notification permission is already granted; `RequestNotificationPermission` asks for it asynchronously |
| Secret store (`StoreSecretAsync`, `LoadSecretAsync`, `DeleteSecretAsync`) | Keychain | Credential Manager | Secret Service (GNOME Keyring, KWallet) | AES-GCM through WebCrypto with a non-extractable key held in IndexedDB; synchronous calls return `ErrSecretStoreUnavailable` |
Expand Down
Loading
Loading