From 09031a34cfc0b0d283e0cc0c0e7e3a5ace989d70 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 21:00:44 +0800 Subject: [PATCH] feat(app): open a local file in the default browser app.OpenPathInBrowser hands a regular file to the default web browser, the application http and https links open in, whatever application the file's type is associated with. An editor that previews HTML needs this: OpenPath opens a page in whatever the .html association names, often the editor itself, and OpenURL refuses file: URLs on purpose. - macOS asks LaunchServices which application opens https links and opens the file with it through open -a, whose exit status reports the handoff. - Windows reads the http protocol's command with AssocQueryStringW, fills the file's file: URL into it as the shell would, and starts it without a shell. - Linux takes the desktop entry xdg-settings names as the default web browser (xdg-mime for x-scheme-handler/https without it), finds it in the XDG data directories and runs its Exec line with the file's URL; a terminal browser is ErrUnsupported. - Without a default browser the call returns app.ErrNoBrowser; the browser target returns ErrUnsupported. - The headless Driver records the request as LastOpenedInBrowser, apart from LastOpenedPath and LastOpenedURL, and starts nothing. The command-line filling, the file URL and the desktop-entry parsing are pure helpers in internal/platform, tested on every host. --- app/driver.go | 16 +- app/driver_system.go | 10 + app/driver_system_unsupported.go | 1 + app/open_in_browser.go | 53 +++ app/open_in_browser_test.go | 62 ++++ app/open_in_browser_unsupported.go | 9 + app/system_darwin.go | 2 + app/system_linux.go | 4 + app/system_windows.go | 2 + docs/system-integrations.md | 1 + internal/platform/browser.go | 314 ++++++++++++++++++ internal/platform/browser_test.go | 153 +++++++++ internal/platform/darwin/system_darwin.go | 22 ++ internal/platform/darwin/system_darwin.h | 3 + internal/platform/darwin/system_darwin.m | 20 ++ .../platform/linux/open_in_browser_linux.go | 93 ++++++ .../windows/open_in_browser_windows.go | 73 ++++ .../content/guides/en/system-integration.md | 3 +- .../content/guides/zh/system-integration.md | 3 +- 19 files changed, 839 insertions(+), 5 deletions(-) create mode 100644 app/open_in_browser.go create mode 100644 app/open_in_browser_test.go create mode 100644 app/open_in_browser_unsupported.go create mode 100644 internal/platform/browser.go create mode 100644 internal/platform/browser_test.go create mode 100644 internal/platform/linux/open_in_browser_linux.go create mode 100644 internal/platform/windows/open_in_browser_windows.go diff --git a/app/driver.go b/app/driver.go index 6074af20..db265530 100644 --- a/app/driver.go +++ b/app/driver.go @@ -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 @@ -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 diff --git a/app/driver_system.go b/app/driver_system.go index 75190a4c..c6174578 100644 --- a/app/driver_system.go +++ b/app/driver_system.go @@ -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 { diff --git a/app/driver_system_unsupported.go b/app/driver_system_unsupported.go index 1f35e4d2..d8d6376f 100644 --- a/app/driver_system_unsupported.go +++ b/app/driver_system_unsupported.go @@ -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 diff --git a/app/open_in_browser.go b/app/open_in_browser.go new file mode 100644 index 00000000..2755f32b --- /dev/null +++ b/app/open_in_browser.go @@ -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 +} diff --git a/app/open_in_browser_test.go b/app/open_in_browser_test.go new file mode 100644 index 00000000..1f139ec1 --- /dev/null +++ b/app/open_in_browser_test.go @@ -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("

x

"), 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("

x

"), 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") + } +} diff --git a/app/open_in_browser_unsupported.go b/app/open_in_browser_unsupported.go new file mode 100644 index 00000000..a0d0bc2a --- /dev/null +++ b/app/open_in_browser_unsupported.go @@ -0,0 +1,9 @@ +//go:build !windows && !darwin && !linux + +package app + +import "fmt" + +func nativeOpenInBrowser(string) error { + return fmt.Errorf("%w: OpenPathInBrowser", ErrUnsupported) +} diff --git a/app/system_darwin.go b/app/system_darwin.go index ce78d83a..f5732795 100644 --- a/app/system_darwin.go +++ b/app/system_darwin.go @@ -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)) } diff --git a/app/system_linux.go b/app/system_linux.go index 0129924c..baa0dd92 100644 --- a/app/system_linux.go +++ b/app/system_linux.go @@ -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))) } diff --git a/app/system_windows.go b/app/system_windows.go index 716398a1..54c6cb67 100644 --- a/app/system_windows.go +++ b/app/system_windows.go @@ -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)) } diff --git a/docs/system-integrations.md b/docs/system-integrations.md index 5eec83a3..edb7b293 100644 --- a/docs/system-integrations.md +++ b/docs/system-integrations.md @@ -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` | diff --git a/internal/platform/browser.go b/internal/platform/browser.go new file mode 100644 index 00000000..9ab30d0d --- /dev/null +++ b/internal/platform/browser.go @@ -0,0 +1,314 @@ +package platform + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// ErrNoBrowser is what a backend returns when the system names no default web +// browser to open a file in. +var ErrNoBrowser = errors.New("platform: no default web browser") + +// FileURL is the file URL a browser is given for an absolute local path. A +// Windows path (windows true) has its separators turned and its drive written +// as file:///C:/..., and a UNC share as file://///host/share/..., the form +// browsers on Windows resolve back to the share; the \\?\ prefix of a long +// path is dropped first. Every space, quote and percent sign comes out escaped, +// so the URL is one word on any command line. +func FileURL(path string, windows bool) string { + if windows { + switch { + case strings.HasPrefix(path, `\\?\UNC\`): + path = `\\` + path[len(`\\?\UNC\`):] + case strings.HasPrefix(path, `\\?\`): + path = path[len(`\\?\`):] + } + // C:/dir becomes /C:/dir and //host/share ///host/share, which a URL + // with no host writes as file:///C:/dir and file://///host/share. + path = "/" + strings.ReplaceAll(path, `\`, "/") + } + return (&url.URL{Scheme: "file", Path: path}).String() +} + +// BrowserCommandLine turns the command Windows registers for a protocol, such +// as `"C:\Program Files\Browser\browser.exe" -url "%1"`, into the program to +// start and the command line to start it with for target, the way the shell +// fills it in: %1 and %L become target and %* nothing, and a command with +// neither %1 nor %L gets target appended. A program path the registry left +// unquoted though it has spaces is taken up to the first space at which exists +// reports a file. target goes in as it is, so it must be one word, as a FileURL +// is. +func BrowserCommandLine(command, target string, exists func(string) bool) (program, commandLine string, err error) { + if target == "" || strings.ContainsAny(target, " \t\"\x00") { + return "", "", fmt.Errorf("platform: browser target %q is not one word", target) + } + command = strings.TrimSpace(command) + var rest string + if strings.HasPrefix(command, `"`) { + end := strings.IndexByte(command[1:], '"') + if end < 0 { + return "", "", fmt.Errorf("platform: browser command %q has an unterminated quote", command) + } + program, rest = command[1:1+end], command[2+end:] + } else { + program, rest = command, "" + if space := strings.IndexByte(command, ' '); space >= 0 { + program, rest = command[:space], command[space:] + } + for index := range command { + if command[index] == ' ' && exists(command[:index]) { + program, rest = command[:index], command[index:] + break + } + } + } + if program == "" { + return "", "", fmt.Errorf("platform: browser command %q names no program", command) + } + var line strings.Builder + line.WriteString(`"` + program + `"`) + placed := false + for index := 0; index < len(rest); index++ { + if rest[index] == '%' && index+1 < len(rest) { + switch rest[index+1] { + case '1', 'L', 'l': + line.WriteString(target) + placed = true + index++ + continue + case '*': + index++ + continue + } + } + line.WriteByte(rest[index]) + } + if !placed { + line.WriteString(" " + target) + } + return program, line.String(), nil +} + +// DesktopEntry is what a freedesktop.org desktop entry file says about starting +// its application: the keys of its [Desktop Entry] group that a launcher needs. +type DesktopEntry struct { + Path string // the file the entry was read from, for %k + Name string + Icon string + Exec string + Hidden bool // the entry was deleted + Terminal bool // the program runs in a terminal +} + +// ParseDesktopEntry reads the [Desktop Entry] group of a desktop entry file, +// undoing the string escapes of the values it keeps; localized keys and other +// groups are ignored. +func ParseDesktopEntry(path string, data []byte) DesktopEntry { + entry := DesktopEntry{Path: path} + inGroup := false + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || line[0] == '#' { + continue + } + if line[0] == '[' { + inGroup = line == "[Desktop Entry]" + continue + } + key, value, ok := strings.Cut(line, "=") + if !inGroup || !ok { + continue + } + value = desktopString(strings.TrimSpace(value)) + switch strings.TrimSpace(key) { + case "Name": + entry.Name = value + case "Icon": + entry.Icon = value + case "Exec": + entry.Exec = value + case "Hidden": + entry.Hidden = value == "true" + case "Terminal": + entry.Terminal = value == "true" + } + } + return entry +} + +// desktopString undoes the escapes of a desktop entry string value. +func desktopString(value string) string { + if !strings.Contains(value, `\`) { + return value + } + var out strings.Builder + for index := 0; index < len(value); index++ { + char := value[index] + if char == '\\' && index+1 < len(value) { + index++ + switch value[index] { + case 's': + char = ' ' + case 'n': + char = '\n' + case 't': + char = '\t' + case 'r': + char = '\r' + case '\\': + char = '\\' + default: + index-- + } + } + out.WriteByte(char) + } + return out.String() +} + +// Arguments is the program and arguments the entry's Exec starts to open one +// file, by the Desktop Entry Specification's quoting rules and field codes: %u +// and %U become uri, %f and %F path, %c the name, %k the entry's own file, %i +// --icon and the icon when there is one, and %% a percent sign; the deprecated +// codes are dropped. An Exec with no file code gets uri appended, since a +// browser that leaves the code out still takes a URL. +func (entry DesktopEntry) Arguments(uri, path string) ([]string, error) { + words, err := desktopExecWords(entry.Exec) + if err != nil { + return nil, err + } + var arguments []string + placed := false + for _, word := range words { + switch word { + case "%u", "%U": + arguments, placed = append(arguments, uri), true + continue + case "%f", "%F": + arguments, placed = append(arguments, path), true + continue + case "%i": + if entry.Icon != "" { + arguments = append(arguments, "--icon", entry.Icon) + } + continue + } + var expanded strings.Builder + for index := 0; index < len(word); index++ { + if word[index] != '%' || index+1 == len(word) { + expanded.WriteByte(word[index]) + continue + } + index++ + switch word[index] { + case '%': + expanded.WriteByte('%') + case 'u', 'U': + expanded.WriteString(uri) + placed = true + case 'f', 'F': + expanded.WriteString(path) + placed = true + case 'c': + expanded.WriteString(entry.Name) + case 'k': + expanded.WriteString(entry.Path) + case 'i': + expanded.WriteString(entry.Icon) + case 'd', 'D', 'n', 'N', 'v', 'm': + default: + expanded.WriteByte('%') + expanded.WriteByte(word[index]) + } + } + if expanded.Len() == 0 && word != "" { + continue + } + arguments = append(arguments, expanded.String()) + } + if len(arguments) == 0 { + return nil, fmt.Errorf("platform: desktop entry %s has no program to run", entry.Path) + } + if !placed { + arguments = append(arguments, uri) + } + return arguments, nil +} + +// desktopExecWords splits an Exec value into its arguments: spaces separate +// them, and inside double quotes a backslash keeps the next quote, backquote, +// dollar sign or backslash. +func desktopExecWords(value string) ([]string, error) { + var words []string + var word strings.Builder + inWord, quoted := false, false + for index := 0; index < len(value); index++ { + char := value[index] + switch { + case quoted && char == '\\' && index+1 < len(value) && strings.IndexByte("\"`$\\", value[index+1]) >= 0: + word.WriteByte(value[index+1]) + index++ + case quoted && char == '"': + quoted = false + case quoted: + word.WriteByte(char) + case char == '"': + quoted, inWord = true, true + case char == ' ' || char == '\t': + if inWord { + words = append(words, word.String()) + word.Reset() + inWord = false + } + default: + word.WriteByte(char) + inWord = true + } + } + if quoted { + return nil, fmt.Errorf("platform: desktop entry Exec %q has an unterminated quote", value) + } + if inWord { + words = append(words, word.String()) + } + return words, nil +} + +// DesktopEntryCandidates lists, in the order a launcher looks, the files a +// desktop file ID such as "org.example.Browser.desktop" can be: under +// applications in the user's data directory and then in each system one, as +// the ID itself and then with its dashes taken for subdirectories. dataHome and +// dataDirs are $XDG_DATA_HOME and $XDG_DATA_DIRS, with their defaults when +// unset; an ID that is not a plain .desktop file name has no candidates. +func DesktopEntryCandidates(id, dataHome, dataDirs, home string) []string { + if !strings.HasSuffix(id, ".desktop") || strings.ContainsAny(id, "/\x00") || strings.HasPrefix(id, ".") { + return nil + } + var roots []string + if strings.HasPrefix(dataHome, "/") { + roots = append(roots, dataHome) + } else if strings.HasPrefix(home, "/") { + roots = append(roots, strings.TrimSuffix(home, "/")+"/.local/share") + } + if dataDirs == "" { + dataDirs = "/usr/local/share:/usr/share" + } + for _, root := range strings.Split(dataDirs, ":") { + if strings.HasPrefix(root, "/") { + roots = append(roots, root) + } + } + names := []string{id} + if nested := strings.ReplaceAll(id, "-", "/"); nested != id { + names = append(names, nested) + } + var candidates []string + for _, root := range roots { + for _, name := range names { + candidates = append(candidates, strings.TrimSuffix(root, "/")+"/applications/"+name) + } + } + return candidates +} diff --git a/internal/platform/browser_test.go b/internal/platform/browser_test.go new file mode 100644 index 00000000..6eb5cdd6 --- /dev/null +++ b/internal/platform/browser_test.go @@ -0,0 +1,153 @@ +package platform + +import ( + "reflect" + "strings" + "testing" +) + +func TestFileURLIsOneWordABrowserResolvesBackToThePath(t *testing.T) { + cases := []struct { + path string + windows bool + want string + }{ + {"/tmp/my page #1.html", false, "file:///tmp/my%20page%20%231.html"}, + {`/tmp/"quoted" 100%.html`, false, "file:///tmp/%22quoted%22%20100%25.html"}, + {"/tmp/页面.html", false, "file:///tmp/%E9%A1%B5%E9%9D%A2.html"}, + {`C:\Users\me\my page.html`, true, "file:///C:/Users/me/my%20page.html"}, + {`\\server.local\share\dir\report.html`, true, "file://///server.local/share/dir/report.html"}, + {`\\?\C:\very\long.html`, true, "file:///C:/very/long.html"}, + {`\\?\UNC\server\share\a.html`, true, "file://///server/share/a.html"}, + } + for _, test := range cases { + got := FileURL(test.path, test.windows) + if got != test.want { + t.Errorf("FileURL(%q, %t) = %q, want %q", test.path, test.windows, got, test.want) + } + if strings.ContainsAny(got, " \t\"") { + t.Errorf("FileURL(%q) = %q is more than one word", test.path, got) + } + } +} + +func TestBrowserCommandLineFillsInTheRegisteredCommand(t *testing.T) { + const target = "file:///C:/a%20b.html" + installed := map[string]bool{`C:\Program Files\Browser\browser.exe`: true} + exists := func(path string) bool { return installed[path] } + cases := []struct { + command, program, line string + }{ + {`"C:\Program Files\Browser\browser.exe" -osint -url "%1"`, `C:\Program Files\Browser\browser.exe`, + `"C:\Program Files\Browser\browser.exe" -osint -url "file:///C:/a%20b.html"`}, + {`"C:\Program Files\Browser\browser.exe" --single-argument %1`, `C:\Program Files\Browser\browser.exe`, + `"C:\Program Files\Browser\browser.exe" --single-argument file:///C:/a%20b.html`}, + {`"C:\Program Files\Browser\browser.exe" "%L" %*`, `C:\Program Files\Browser\browser.exe`, + `"C:\Program Files\Browser\browser.exe" "file:///C:/a%20b.html" `}, + {`"C:\Program Files\Browser\browser.exe"`, `C:\Program Files\Browser\browser.exe`, + `"C:\Program Files\Browser\browser.exe" file:///C:/a%20b.html`}, + {`C:\Program Files\Browser\browser.exe -- "%1"`, `C:\Program Files\Browser\browser.exe`, + `"C:\Program Files\Browser\browser.exe" -- "file:///C:/a%20b.html"`}, + {`C:\Tools\browser.exe %1`, `C:\Tools\browser.exe`, `"C:\Tools\browser.exe" file:///C:/a%20b.html`}, + {`C:\Tools\browser.exe`, `C:\Tools\browser.exe`, `"C:\Tools\browser.exe" file:///C:/a%20b.html`}, + {`"C:\Tools\browser.exe" -url %1 -- %2`, `C:\Tools\browser.exe`, `"C:\Tools\browser.exe" -url file:///C:/a%20b.html -- %2`}, + } + for _, test := range cases { + program, line, err := BrowserCommandLine(test.command, target, exists) + if err != nil || program != test.program || line != test.line { + t.Errorf("BrowserCommandLine(%q) = %q, %q, %v\nwant %q, %q", test.command, program, line, err, test.program, test.line) + } + } + for _, command := range []string{`"C:\Program Files\browser.exe -url %1`, `""`, ``} { + if _, _, err := BrowserCommandLine(command, target, exists); err == nil { + t.Errorf("BrowserCommandLine(%q) was accepted", command) + } + } + for _, unsafe := range []string{"", "a b", `a"b`} { + if _, _, err := BrowserCommandLine(`"C:\b.exe" %1`, unsafe, exists); err == nil { + t.Errorf("target %q was accepted", unsafe) + } + } +} + +func TestDesktopEntryKeepsOnlyItsOwnGroup(t *testing.T) { + entry := ParseDesktopEntry("/usr/share/applications/browser.desktop", []byte(`# a comment +[Desktop Entry] +Name=Web\sBrowser +Name[fr]=Navigateur +Icon = browser +Exec=browser %u +Terminal=false + +[Desktop Action new-window] +Exec=browser --new-window %u +Hidden=true +`)) + want := DesktopEntry{Path: "/usr/share/applications/browser.desktop", Name: "Web Browser", Icon: "browser", Exec: "browser %u"} + if entry != want { + t.Fatalf("entry = %+v, want %+v", entry, want) + } + hidden := ParseDesktopEntry("", []byte("[Desktop Entry]\nHidden=true\nTerminal=true\n")) + if !hidden.Hidden || !hidden.Terminal { + t.Fatalf("hidden entry = %+v", hidden) + } +} + +func TestDesktopEntryArgumentsFollowTheSpecification(t *testing.T) { + const uri, path = "file:///tmp/a%20b.html", "/tmp/a b.html" + cases := []struct { + exec string + want []string + }{ + {"firefox %u", []string{"firefox", uri}}, + {"/usr/bin/chromium --enable-features=X %U", []string{"/usr/bin/chromium", "--enable-features=X", uri}}, + {"viewer %f", []string{"viewer", path}}, + {"browser", []string{"browser", uri}}, + {"env BAMF_DESKTOP_FILE_HINT=/x.desktop /snap/bin/firefox %u", []string{"env", "BAMF_DESKTOP_FILE_HINT=/x.desktop", "/snap/bin/firefox", uri}}, + {"/usr/bin/flatpak run --file-forwarding org.example.Browser @@u %u @@", []string{"/usr/bin/flatpak", "run", "--file-forwarding", "org.example.Browser", "@@u", uri, "@@"}}, + {`"/opt/My Browser/browser" --name="%c" %i %m %u`, []string{"/opt/My Browser/browser", "--name=Web Browser", "--icon", "browser", uri}}, + {`sh -c "echo \"\$1\" \\\\" %k 100%% %u`, []string{"sh", "-c", `echo "$1" \\`, "/apps/browser.desktop", "100%", uri}}, + {`browser "" %u`, []string{"browser", "", uri}}, + {"browser --url=%u", []string{"browser", "--url=" + uri}}, + } + for _, test := range cases { + entry := DesktopEntry{Path: "/apps/browser.desktop", Name: "Web Browser", Icon: "browser", Exec: test.exec} + got, err := entry.Arguments(uri, path) + if err != nil || !reflect.DeepEqual(got, test.want) { + t.Errorf("Exec=%s: %q, %v\nwant %q", test.exec, got, err, test.want) + } + } + for _, exec := range []string{`browser "%u`, "", "%m"} { + if got, err := (DesktopEntry{Exec: exec}).Arguments(uri, path); err == nil { + t.Errorf("Exec=%s was accepted: %q", exec, got) + } + } + if got, _ := (DesktopEntry{Exec: "browser %i %u"}).Arguments(uri, path); !reflect.DeepEqual(got, []string{"browser", uri}) { + t.Errorf("%%i without an icon = %q", got) + } +} + +func TestDesktopEntryCandidatesFollowTheDataDirectories(t *testing.T) { + got := DesktopEntryCandidates("kde-browser.desktop", "", "/opt/share/:relative:/usr/share", "/home/me") + want := []string{ + "/home/me/.local/share/applications/kde-browser.desktop", + "/home/me/.local/share/applications/kde/browser.desktop", + "/opt/share/applications/kde-browser.desktop", + "/opt/share/applications/kde/browser.desktop", + "/usr/share/applications/kde-browser.desktop", + "/usr/share/applications/kde/browser.desktop", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("candidates = %q\nwant %q", got, want) + } + got = DesktopEntryCandidates("firefox.desktop", "/data", "", "/home/me") + want = []string{"/data/applications/firefox.desktop", "/usr/local/share/applications/firefox.desktop", "/usr/share/applications/firefox.desktop"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("defaults = %q\nwant %q", got, want) + } + for _, id := range []string{"firefox", "../evil.desktop", "a/b.desktop", ".desktop", "a\x00.desktop"} { + if got := DesktopEntryCandidates(id, "", "", "/home/me"); got != nil { + t.Errorf("%q has candidates %q", id, got) + } + } +} diff --git a/internal/platform/darwin/system_darwin.go b/internal/platform/darwin/system_darwin.go index 3f04e1b6..4577f0f5 100644 --- a/internal/platform/darwin/system_darwin.go +++ b/internal/platform/darwin/system_darwin.go @@ -12,6 +12,8 @@ import "C" import ( "errors" "fmt" + "os/exec" + "path/filepath" "runtime" "strings" "sync/atomic" @@ -411,6 +413,26 @@ func SchemeHandlerAvailable(scheme string) (bool, error) { return status == 1, nil } +// OpenInBrowser opens the file at path in the default browser, the application +// https links open in, through open, whose exit status reports whether the +// handoff worked. Without a default browser it returns platform.ErrNoBrowser. +func OpenInBrowser(path string) error { + var nativePath, nativeError *C.char + switch C.viva_system_default_browser(&nativePath, &nativeError) { + case 0: + return fmt.Errorf("darwin: open in browser: %w", platform.ErrNoBrowser) + case 1: + default: + return systemError("look up the default browser", nativeError) + } + browser := C.GoString(nativePath) + C.viva_system_free(unsafe.Pointer(nativePath)) + if err := exec.Command("/usr/bin/open", "-a", browser, path).Run(); err != nil { + return fmt.Errorf("darwin: open in %s: %w", filepath.Base(browser), err) + } + return nil +} + func PlaySystemSound(sound uint8) error { var nativeError *C.char if C.viva_system_play_sound(C.uint8_t(sound), &nativeError) != 0 { diff --git a/internal/platform/darwin/system_darwin.h b/internal/platform/darwin/system_darwin.h index b55e5755..a0b9d930 100644 --- a/internal/platform/darwin/system_darwin.h +++ b/internal/platform/darwin/system_darwin.h @@ -73,6 +73,9 @@ int viva_system_reveal_path(const char *path, char **error_message); // Whether any application is registered for the URL scheme (no trailing colon): // 1 yes, 0 no, -1 failure with *error_message. int viva_system_scheme_handler(const char *scheme, char **error_message); +// The application https links open in, the default browser: 1 with *path set, +// 0 when there is none, -1 failure with *error_message. +int viva_system_default_browser(char **path, char **error_message); int viva_system_reduce_motion(void); char *viva_system_preferred_languages(void); int viva_system_dark_appearance(void); diff --git a/internal/platform/darwin/system_darwin.m b/internal/platform/darwin/system_darwin.m index 04a68567..4addd74d 100644 --- a/internal/platform/darwin/system_darwin.m +++ b/internal/platform/darwin/system_darwin.m @@ -1117,6 +1117,26 @@ int viva_system_scheme_handler(const char *scheme, char **error_message) { } } +int viva_system_default_browser(char **path, char **error_message) { + // Like the scheme lookup, LaunchServices answers this from any thread. + @autoreleasepool { + NSURL *probe = [NSURL URLWithString:@"https://example.invalid/"]; + NSURL *browser = [NSWorkspace.sharedWorkspace URLForApplicationToOpenURL:probe]; + if (browser == nil) { + return 0; + } + NSString *value = browser.path; + if (value.length == 0) { + if (error_message != NULL) { + *error_message = viva_copy_utf8(@"the default browser has no file path"); + } + return -1; + } + *path = viva_copy_utf8(value); + return 1; + } +} + int viva_system_play_sound(uint8_t sound, char **error_message) { __block int status = 0; __block char *failure = NULL; diff --git a/internal/platform/linux/open_in_browser_linux.go b/internal/platform/linux/open_in_browser_linux.go new file mode 100644 index 00000000..02b927b5 --- /dev/null +++ b/internal/platform/linux/open_in_browser_linux.go @@ -0,0 +1,93 @@ +package linux + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/vivaui/viva/internal/platform" +) + +// browserQueryTimeout bounds asking xdg-settings or xdg-mime for the default +// browser, which are shell scripts that can hang on a broken session. +const browserQueryTimeout = 5 * time.Second + +// OpenInBrowser opens the file at path in the default browser: the desktop +// entry xdg-settings names as the default web browser, or the one xdg-mime +// gives https links to, is started with the file's URL by its Exec line, +// without a shell, and reaped in the background once it exits. Without a +// default browser it returns platform.ErrNoBrowser. +func OpenInBrowser(path string) error { + id, err := defaultBrowserID() + if err != nil { + return err + } + entry, err := readDesktopEntry(id) + if err != nil { + return err + } + if entry.Hidden { + return fmt.Errorf("linux: open in browser: %s is hidden: %w", id, platform.ErrNoBrowser) + } + if entry.Terminal { + return unsupported(fmt.Sprintf("the default browser %s runs in a terminal", id)) + } + arguments, err := entry.Arguments(platform.FileURL(path, false), path) + if err != nil { + return fmt.Errorf("linux: open in browser: %w", err) + } + if err := platform.StartReaped(exec.Command(arguments[0], arguments[1:]...)); err != nil { + return fmt.Errorf("linux: launch %s: %w", filepath.Base(arguments[0]), err) + } + return nil +} + +// defaultBrowserID is the desktop file ID of the default browser. +func defaultBrowserID() (string, error) { + queries := []desktopCommand{ + {name: "xdg-settings", arguments: []string{"get", "default-web-browser"}}, + {name: "xdg-mime", arguments: []string{"query", "default", "x-scheme-handler/https"}}, + } + asked := false + for _, query := range queries { + program, err := exec.LookPath(query.name) + if err != nil { + continue + } + asked = true + ctx, cancel := context.WithTimeout(context.Background(), browserQueryTimeout) + output, err := exec.CommandContext(ctx, program, query.arguments...).Output() + cancel() + if err != nil { + continue + } + id, _, _ := strings.Cut(strings.TrimSpace(string(output)), ";") + if id = strings.TrimSpace(id); id != "" { + return id, nil + } + } + if !asked { + return "", unsupported("opening a file in the browser requires xdg-settings or xdg-mime") + } + return "", fmt.Errorf("linux: open in browser: %w", platform.ErrNoBrowser) +} + +func readDesktopEntry(id string) (platform.DesktopEntry, error) { + home, _ := os.UserHomeDir() + for _, candidate := range platform.DesktopEntryCandidates(id, os.Getenv("XDG_DATA_HOME"), os.Getenv("XDG_DATA_DIRS"), home) { + data, err := os.ReadFile(candidate) + if err == nil { + return platform.ParseDesktopEntry(candidate, data), nil + } + if !errors.Is(err, fs.ErrNotExist) { + return platform.DesktopEntry{}, fmt.Errorf("linux: read %s: %w", candidate, err) + } + } + return platform.DesktopEntry{}, fmt.Errorf("linux: open in browser: the default browser %q has no desktop entry: %w", id, platform.ErrNoBrowser) +} diff --git a/internal/platform/windows/open_in_browser_windows.go b/internal/platform/windows/open_in_browser_windows.go new file mode 100644 index 00000000..3328d8f0 --- /dev/null +++ b/internal/platform/windows/open_in_browser_windows.go @@ -0,0 +1,73 @@ +//go:build windows + +package windows + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "github.com/vivaui/viva/internal/platform" +) + +// HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND): the protocol has no key at all, as +// hresultNoAssociation means it has no command. +const hresultFileNotFound = 0x80070002 + +// OpenInBrowser opens the file at path in the default browser by starting the +// command Windows registers for http links with the file's URL, without a +// shell, as the shell itself fills the command in; the browser is reaped in the +// background once it exits. Without a default browser it returns +// platform.ErrNoBrowser. +func OpenInBrowser(path string) error { + command, err := browserCommand() + if err != nil { + return err + } + program, commandLine, err := platform.BrowserCommandLine(command, platform.FileURL(path, true), regularFile) + if err != nil { + return fmt.Errorf("windows: open in browser: %w", err) + } + browser := exec.Command(program) + browser.SysProcAttr = &syscall.SysProcAttr{CmdLine: commandLine} + if err := platform.StartReaped(browser); err != nil { + return fmt.Errorf("windows: launch %s: %w", filepath.Base(program), err) + } + return nil +} + +// browserCommand is the command line Windows starts http links with, the +// default browser's. +func browserCommand() (string, error) { + protocol, _ := syscall.UTF16PtrFromString("http") + var length uint32 + status, _, _ := assocQueryString.Call(assocfIsProtocol, assocStrCommand, uintptr(unsafe.Pointer(protocol)), 0, 0, uintptr(unsafe.Pointer(&length))) + if code := uint32(status); code == hresultNoAssociation || code == hresultFileNotFound { + return "", fmt.Errorf("windows: open in browser: %w", platform.ErrNoBrowser) + } + if err := hresult(status); err != nil { + return "", fmt.Errorf("windows: look up the default browser: %w", err) + } + if length == 0 { + return "", fmt.Errorf("windows: open in browser: %w", platform.ErrNoBrowser) + } + buffer := make([]uint16, length) + status, _, _ = assocQueryString.Call(assocfIsProtocol, assocStrCommand, uintptr(unsafe.Pointer(protocol)), 0, uintptr(unsafe.Pointer(&buffer[0])), uintptr(unsafe.Pointer(&length))) + if err := hresult(status); err != nil { + return "", fmt.Errorf("windows: look up the default browser: %w", err) + } + command := strings.TrimSpace(syscall.UTF16ToString(buffer)) + if command == "" { + return "", fmt.Errorf("windows: open in browser: %w", platform.ErrNoBrowser) + } + return command, nil +} + +func regularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} diff --git a/website/content/guides/en/system-integration.md b/website/content/guides/en/system-integration.md index e4aad5ec..b89cd1ed 100644 --- a/website/content/guides/en/system-integration.md +++ b/website/content/guides/en/system-integration.md @@ -13,6 +13,7 @@ The `app` package exposes a small, synchronous set of system integrations. An op | File dialogs | `app.OpenFiles`, `app.OpenDirectory`, `app.SaveFile` | | Open a URL | `app.OpenURL` | | Open a local document in its default application (never a program) | `app.OpenPath` | +| Open a local file, such as an HTML page, in the default browser whatever opens its type | `app.OpenPathInBrowser` | | Notifications and sounds | `app.SendNotification`, `app.PlaySystemSound` | | Cursor | `app.SetCursor` | | Preferred languages | `app.PreferredLanguages` | @@ -27,7 +28,7 @@ The `app` package exposes a small, synchronous set of system integrations. An op ## What each platform uses -Each function maps to the platform's own API: `NSPasteboard`, `NSOpenPanel` and `NSWorkspace` on macOS; the Win32 clipboard, `IFileOpenDialog` and `ShellExecuteW` on Windows; X11 selections on Linux, with the desktop portal's file chooser and the notification service over D-Bus (`zenity`, `kdialog` and `notify-send` when a desktop has neither), and `xdg-open` or `gio` for URLs. Secrets go to the Keychain, Credential Manager or Secret Service. +Each function maps to the platform's own API: `NSPasteboard`, `NSOpenPanel` and `NSWorkspace` on macOS; the Win32 clipboard, `IFileOpenDialog` and `ShellExecuteW` on Windows; X11 selections on Linux, with the desktop portal's file chooser and the notification service over D-Bus (`zenity`, `kdialog` and `notify-send` when a desktop has neither), and `xdg-open` or `gio` for URLs. The default browser is the application LaunchServices opens `https` links in on macOS, the `http` protocol's registered command on Windows, and on Linux the desktop entry `xdg-settings` names. Secrets go to the Keychain, Credential Manager or Secret Service. The full table, including the cases where a platform cannot do something, is the repository's `docs/system-integrations.md`. diff --git a/website/content/guides/zh/system-integration.md b/website/content/guides/zh/system-integration.md index 938020ad..17c93ca3 100644 --- a/website/content/guides/zh/system-integration.md +++ b/website/content/guides/zh/system-integration.md @@ -13,6 +13,7 @@ group: guides | 文件对话框 | `app.OpenFiles`、`app.OpenDirectory`、`app.SaveFile` | | 打开 URL | `app.OpenURL` | | 用默认应用打开本地文档(绝不运行程序) | `app.OpenPath` | +| 用默认浏览器打开本地文件(如 HTML 页面),不论该类型关联到哪个应用 | `app.OpenPathInBrowser` | | 通知与声音 | `app.SendNotification`、`app.PlaySystemSound` | | 光标 | `app.SetCursor` | | 系统首选语言 | `app.PreferredLanguages` | @@ -27,7 +28,7 @@ group: guides ## 各平台用什么实现 -每个函数都映射到平台自己的接口:macOS 上是 `NSPasteboard`、`NSOpenPanel` 和 `NSWorkspace`;Windows 上是 Win32 剪贴板、`IFileOpenDialog` 和 `ShellExecuteW`;Linux 上是 X11 选区,文件对话框使用 `zenity` 或 `kdialog`,打开 URL 使用 `xdg-open` 或 `gio`,通知使用 `notify-send`。密钥存入钥匙串、凭据管理器或 Secret Service。 +每个函数都映射到平台自己的接口:macOS 上是 `NSPasteboard`、`NSOpenPanel` 和 `NSWorkspace`;Windows 上是 Win32 剪贴板、`IFileOpenDialog` 和 `ShellExecuteW`;Linux 上是 X11 选区,文件对话框使用 `zenity` 或 `kdialog`,打开 URL 使用 `xdg-open` 或 `gio`,通知使用 `notify-send`。默认浏览器在 macOS 上是 LaunchServices 打开 `https` 链接所用的应用,在 Windows 上是 `http` 协议登记的命令,在 Linux 上是 `xdg-settings` 给出的桌面文件。密钥存入钥匙串、凭据管理器或 Secret Service。 完整的表格,包括某个平台做不到某件事的情形,见仓库中的 `docs/system-integrations.md`。