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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ cd packages/client && npm ci && npm run build
- The server binds `127.0.0.1` only. Never bind other interfaces.
- One generic endpoint philosophy: no app-specific business logic, auth, or branding in this repo.
- A missing path must stay a real error (`404` + `code: "not_found"`) — never fall back to opening a different location.
- Tests must never open a real file browser window: only exercise invalid-path and handler-level error cases.
- Tests must never open a real file browser window or mount a network share: only exercise invalid-path, URL-parsing, and handler-level error cases.
- Icons are generated from the SVGs in `assets/` (rsvg-convert + ImageMagick); regenerate rather than hand-editing the PNGs/ICO.
- macOS tray uses the template icon (`assets/tray-template.png`); Windows uses `icon.ico`; Linux uses `tray.png`.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ Errors are JSON with a stable `code`:

Paths must be absolute. Windows UNC paths (`\\server\share\...`) count as absolute.

### SMB share URLs

`path` can also be an `smb://server/share/...` URL, so one payload works on every OS even when the share isn't mounted yet:

- **macOS** mounts the share on demand the same way Finder does (NetFS: mounts under `/Volumes`, uses Keychain credentials, shows the standard authentication dialog only when needed) — no extra Finder window at the share root.
- **Windows** opens the equivalent UNC path (`\\server\share\...`) directly; Windows connects and authenticates natively.
- **Linux** mounts the share through gvfs (`gio mount`) and opens the path inside the user's gvfs FUSE mount.

```bash
curl -X POST http://localhost:29101/open \
-H 'Content-Type: application/json' \
-d '{"path": "smb://storage/Signature Coins/12345"}'
```

Percent-encoding is optional — literal spaces are accepted. A missing folder below the share is still a real `404`/`not_found`; a share that can't be mounted is a `500` with the mount error.

CORS is permissive (any origin): the server only ever opens the local file browser, and it answers Chrome's Private Network Access preflight so pages on public origins can reach `localhost`. Chrome will still ask the user once for "local network access" permission per origin — same as Dazzle.

## Client library
Expand Down
8 changes: 8 additions & 0 deletions open.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@ var (
// Directories open directly; files are revealed (selected) in their parent
// folder. Unlike Windows Explorer's default behavior, a missing path is a
// real error — we never silently open a fallback location.
//
// smb:// URLs are resolved to a local path first (mounting the share on
// demand where the platform needs it) and then opened like any other path.
func openPath(path string) (action string, err error) {
path = strings.TrimSpace(path)
if path == "" {
return "", fmt.Errorf("%w: empty path", errBadPath)
}
if isSMBURL(path) {
if path, err = resolveSMB(path); err != nil {
return "", err
}
}
if !filepath.IsAbs(path) {
return "", fmt.Errorf("%w: path must be absolute: %q", errBadPath, path)
}
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export class FolderOpener {
* Open a folder in the system's file browser. If the path is a file, it is
* revealed (selected) in its parent folder instead.
*
* Accepts an absolute local path, a Windows UNC path, or (server v0.2+) an
* `smb://server/share/...` URL — the server mounts the share on demand
* where the platform needs it (macOS/Linux), so one payload works on
* every OS.
*
* Throws a `FolderOpenerError` with `code: 'not_found'` when the path does
* not exist on the machine — unlike the legacy protocol-handler approach,
* a missing folder is a real, detectable error.
Expand Down
42 changes: 42 additions & 0 deletions smb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"fmt"
"net/url"
"strings"
)

// isSMBURL reports whether the request path is an smb:// share URL rather
// than a local filesystem path.
func isSMBURL(path string) bool {
return len(path) >= 6 && strings.EqualFold(path[:6], "smb://")
}

// parseSMB splits an smb:// URL into the parsed URL, the share name (first
// path segment), and the path segments below the share. Percent-escapes are
// decoded; literal spaces are accepted as-is.
func parseSMB(raw string) (u *url.URL, share string, rest []string, err error) {
u, err = url.Parse(raw)
if err != nil {
return nil, "", nil, fmt.Errorf("%w: %v", errBadPath, err)
}
if u.Hostname() == "" {
return nil, "", nil, fmt.Errorf("%w: smb URL missing host: %q", errBadPath, raw)
}
for segment := range strings.SplitSeq(u.Path, "/") {
if segment != "" {
rest = append(rest, segment)
}
}
if len(rest) == 0 {
return nil, "", nil, fmt.Errorf("%w: smb URL missing share name: %q", errBadPath, raw)
}
return u, rest[0], rest[1:], nil
}

// smbShareURL builds a properly escaped smb:// URL for just the share root,
// preserving any user info and port from the original URL.
func smbShareURL(u *url.URL, share string) string {
root := url.URL{Scheme: "smb", User: u.User, Host: u.Host, Path: "/" + share}
return root.String()
}
206 changes: 206 additions & 0 deletions smb_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package main

/*
#cgo LDFLAGS: -framework NetFS -framework CoreFoundation
#include <errno.h>
#include <stdlib.h>
#include <CoreFoundation/CoreFoundation.h>
#include <NetFS/NetFS.h>

// mountSMB mounts an smb:// share URL the way Finder does: under /Volumes,
// using Keychain credentials — without opening any Finder window. With
// allowUI false all NetFS UI is suppressed, so a failed mount comes back as
// an error code instead of the system "There was a problem connecting to
// the server" alert; pass allowUI true to let the standard authentication
// dialog appear. On success the mountpoint is returned in *mountpoint
// (caller frees).
static int mountSMB(const char *url, int allowUI, char **mountpoint) {
CFStringRef urlString = CFStringCreateWithCString(NULL, url, kCFStringEncodingUTF8);
if (urlString == NULL) {
return EINVAL;
}
CFURLRef shareURL = CFURLCreateWithString(NULL, urlString, NULL);
CFRelease(urlString);
if (shareURL == NULL) {
return EINVAL;
}

CFMutableDictionaryRef openOptions = CFDictionaryCreateMutable(NULL, 0,
&kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
if (openOptions != NULL && !allowUI) {
CFDictionarySetValue(openOptions, kNAUIOptionKey, kNAUIOptionNoUI);
}

CFArrayRef mountpoints = NULL;
int rc = NetFSMountURLSync(shareURL, NULL, NULL, NULL, openOptions, NULL, &mountpoints);
CFRelease(shareURL);
if (openOptions != NULL) {
CFRelease(openOptions);
}

if (rc == 0) {
if (mountpoints == NULL || CFArrayGetCount(mountpoints) == 0) {
rc = EIO;
} else {
CFStringRef mp = CFArrayGetValueAtIndex(mountpoints, 0);
CFIndex size = CFStringGetMaximumSizeForEncoding(CFStringGetLength(mp), kCFStringEncodingUTF8) + 1;
*mountpoint = malloc(size);
if (*mountpoint == NULL || !CFStringGetCString(mp, *mountpoint, size, kCFStringEncodingUTF8)) {
free(*mountpoint);
*mountpoint = NULL;
rc = EIO;
}
}
}
if (mountpoints != NULL) {
CFRelease(mountpoints);
}
return rc;
}
*/
import "C"

import (
"fmt"
"net/url"
"path/filepath"
"strings"
"syscall"
"unsafe"

"golang.org/x/sys/unix"
)

// resolveSMB turns an smb:// URL into a local /Volumes path, mounting the
// share on demand so a single click works even when it isn't mounted yet.
func resolveSMB(raw string) (string, error) {
u, share, rest, err := parseSMB(raw)
if err != nil {
return "", err
}

mountpoint, err := mountedShare(u.Hostname(), share)
if err != nil {
return "", err
}
if mountpoint == "" {
if mountpoint, err = mountShare(u, share); err != nil {
return "", err
}
}
return filepath.Join(append([]string{mountpoint}, rest...)...), nil
}

func mountShare(u *url.URL, share string) (string, error) {
shareURL := smbShareURL(u, share)

// Mount silently first (Keychain or saved credentials), so failures come
// back as clean errors for the caller instead of the system "There was a
// problem connecting to the server" alert. Only when the mount fails for
// lack of credentials retry with UI allowed, letting the standard macOS
// authentication dialog appear (and save to the Keychain).
mountpoint, rc := mountOnce(shareURL, false)
if needsAuthUI(rc) {
mountpoint, rc = mountOnce(shareURL, true)
}

switch rc {
case 0:
return mountpoint, nil
case C.EEXIST:
// Lost a mount race; the share is there now, find its mountpoint.
mountpoint, err := mountedShare(u.Hostname(), share)
if err == nil && mountpoint != "" {
return mountpoint, nil
}
return "", fmt.Errorf("mount %s: already mounted but mountpoint not found", shareURL)
case C.ENOENT:
return "", fmt.Errorf("%w: no such share: %s", errNotFound, shareURL)
case C.ECANCELED:
return "", fmt.Errorf("mount %s: authentication canceled", shareURL)
default:
if rc > 0 {
return "", fmt.Errorf("mount %s: %w", shareURL, syscall.Errno(rc))
}
// Negative codes are NetFS-specific (auth/UI errors from NetFS.h).
return "", fmt.Errorf("mount %s: NetFS error %d", shareURL, int(rc))
}
}

func mountOnce(shareURL string, allowUI bool) (mountpoint string, rc C.int) {
urlC := C.CString(shareURL)
defer C.free(unsafe.Pointer(urlC))

allowUIC := C.int(0)
if allowUI {
allowUIC = 1
}
var mountpointC *C.char
rc = C.mountSMB(urlC, allowUIC, &mountpointC)
if mountpointC != nil {
defer C.free(unsafe.Pointer(mountpointC))
}
return C.GoString(mountpointC), rc
}

// needsAuthUI reports whether a silent mount failed specifically because
// credentials are missing or rejected — the cases the auth dialog can fix.
func needsAuthUI(rc C.int) bool {
switch rc {
case C.EAUTH, C.ENEEDAUTH, C.EACCES, C.EPERM:
return true
}
return false
}

// mountedShare scans the mount table for an existing smbfs mount of
// //host/share and returns its mountpoint, or "" when it isn't mounted.
func mountedShare(host, share string) (string, error) {
n, err := unix.Getfsstat(nil, unix.MNT_NOWAIT)
if err != nil {
return "", fmt.Errorf("getfsstat: %w", err)
}
stats := make([]unix.Statfs_t, n)
if _, err := unix.Getfsstat(stats, unix.MNT_NOWAIT); err != nil {
return "", fmt.Errorf("getfsstat: %w", err)
}

for _, stat := range stats {
if unix.ByteSliceToString(stat.Fstypename[:]) != "smbfs" {
continue
}
mountHost, mountShare, ok := splitMntFromName(unix.ByteSliceToString(stat.Mntfromname[:]))
if ok && strings.EqualFold(mountHost, host) && strings.EqualFold(mountShare, share) {
return unix.ByteSliceToString(stat.Mntonname[:]), nil
}
}
return "", nil
}

// splitMntFromName extracts host and share from an smbfs mount source like
// "//user@host/share" or "//GUEST:@host:445/share"; both parts may be
// percent-encoded.
func splitMntFromName(from string) (host, share string, ok bool) {
from, ok = strings.CutPrefix(from, "//")
if !ok {
return "", "", false
}
authority, share, ok := strings.Cut(from, "/")
if !ok || share == "" {
return "", "", false
}
if at := strings.LastIndex(authority, "@"); at >= 0 {
authority = authority[at+1:]
}
if colon := strings.LastIndex(authority, ":"); colon >= 0 {
authority = authority[:colon]
}
return pathUnescaped(authority), pathUnescaped(share), true
}

func pathUnescaped(s string) string {
if unescaped, err := url.PathUnescape(s); err == nil {
return unescaped
}
return s
}
28 changes: 28 additions & 0 deletions smb_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import "testing"

func TestSplitMntFromName(t *testing.T) {
t.Parallel()

tests := []struct {
from string
wantHost string
wantShare string
wantOK bool
}{
{"//brian@storage/Art", "storage", "Art", true},
{"//storage/Art", "storage", "Art", true},
{"//GUEST:@storage/Signature%20Coins", "storage", "Signature Coins", true},
{"//DOMAIN;brian@storage:445/Art", "storage", "Art", true},
{"/dev/disk3s1", "", "", false},
{"//storage", "", "", false},
}
for _, test := range tests {
host, share, ok := splitMntFromName(test.from)
if host != test.wantHost || share != test.wantShare || ok != test.wantOK {
t.Errorf("splitMntFromName(%q) = (%q, %q, %v), want (%q, %q, %v)",
test.from, host, share, ok, test.wantHost, test.wantShare, test.wantOK)
}
}
}
Loading
Loading