From 17762cc4667c3a174e5859a3feff7c0c19ab2b46 Mon Sep 17 00:00:00 2001 From: Brian Leishman Date: Wed, 8 Jul 2026 12:28:00 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20accept=20smb://=20URLs=20with=20mount-o?= =?UTF-8?q?n-demand=20=E2=80=94=20true=20one-click=20on=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /open now takes smb://server/share/... URLs in addition to local paths, so one payload works on every OS even when the share isn't mounted yet: - macOS mounts on demand via the NetFS framework — the same path Finder uses (mounts under /Volumes, Keychain credentials) but with no Finder window at the share root. Mounts run silently first (kNAUIOptionNoUI) so a failed mount returns a clean JSON error instead of the system "There was a problem connecting to the server" alert; the standard auth dialog is only allowed when the failure is missing credentials. Already-mounted shares are matched in the mount table (getfsstat), including percent-encoded mntfromname entries. - Windows translates to the equivalent UNC path, which resolves natively. - Linux mounts through gvfs (gio mount) and opens the path inside the user's gvfs FUSE mount. Live-tested against a real SMB share from a fully unmounted state: silent mount + exactly one Finder window at the target folder; missing subfolders still 404, bad share names return JSON 500 with no dialogs. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 2 +- README.md | 16 +++ open.go | 8 ++ packages/client/src/index.ts | 5 + smb.go | 42 +++++++ smb_darwin.go | 206 +++++++++++++++++++++++++++++++++++ smb_darwin_test.go | 28 +++++ smb_linux.go | 68 ++++++++++++ smb_test.go | 82 ++++++++++++++ smb_windows.go | 13 +++ 10 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 smb.go create mode 100644 smb_darwin.go create mode 100644 smb_darwin_test.go create mode 100644 smb_linux.go create mode 100644 smb_test.go create mode 100644 smb_windows.go diff --git a/AGENTS.md b/AGENTS.md index 45e85d8..3253dee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md index d857153..fcfb091 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/open.go b/open.go index d358f5e..e523292 100644 --- a/open.go +++ b/open.go @@ -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) } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 92f784c..7c29b4b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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. diff --git a/smb.go b/smb.go new file mode 100644 index 0000000..644100e --- /dev/null +++ b/smb.go @@ -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() +} diff --git a/smb_darwin.go b/smb_darwin.go new file mode 100644 index 0000000..64ce4d4 --- /dev/null +++ b/smb_darwin.go @@ -0,0 +1,206 @@ +package main + +/* +#cgo LDFLAGS: -framework NetFS -framework CoreFoundation +#include +#include +#include +#include + +// 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 +} diff --git a/smb_darwin_test.go b/smb_darwin_test.go new file mode 100644 index 0000000..a20aa93 --- /dev/null +++ b/smb_darwin_test.go @@ -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) + } + } +} diff --git a/smb_linux.go b/smb_linux.go new file mode 100644 index 0000000..2978d95 --- /dev/null +++ b/smb_linux.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// resolveSMB turns an smb:// URL into a path inside the user's gvfs FUSE +// mount, mounting the share on demand via `gio mount`. +func resolveSMB(raw string) (string, error) { + u, share, rest, err := parseSMB(raw) + if err != nil { + return "", err + } + host := u.Hostname() + + dir, ok := gvfsShareDir(host, share) + if !ok { + // gio also exits non-zero when the share is already mounted, so only + // fail if the share still isn't visible afterwards. + mountErr := runQuiet(exec.Command("gio", "mount", smbShareURL(u, share))) + if dir, ok = gvfsShareDir(host, share); !ok { + if mountErr != nil { + return "", fmt.Errorf("mount %s: %w", smbShareURL(u, share), mountErr) + } + return "", fmt.Errorf("mount %s: mounted but gvfs directory not found", smbShareURL(u, share)) + } + } + return filepath.Join(append([]string{dir}, rest...)...), nil +} + +// gvfsShareDir finds the gvfs FUSE directory for an already-mounted smb +// share, e.g. /run/user/1000/gvfs/smb-share:server=host,share=name. +func gvfsShareDir(host, share string) (string, bool) { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid()) + } + gvfs := filepath.Join(runtimeDir, "gvfs") + entries, err := os.ReadDir(gvfs) + if err != nil { + return "", false + } + + for _, entry := range entries { + fields, found := strings.CutPrefix(entry.Name(), "smb-share:") + if !found { + continue + } + var entryHost, entryShare string + for field := range strings.SplitSeq(fields, ",") { + key, value, _ := strings.Cut(field, "=") + switch key { + case "server": + entryHost = value + case "share": + entryShare = value + } + } + if strings.EqualFold(entryHost, host) && strings.EqualFold(entryShare, share) { + return filepath.Join(gvfs, entry.Name()), true + } + } + return "", false +} diff --git a/smb_test.go b/smb_test.go new file mode 100644 index 0000000..9307865 --- /dev/null +++ b/smb_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "errors" + "slices" + "testing" +) + +func TestIsSMBURL(t *testing.T) { + t.Parallel() + + tests := []struct { + path string + want bool + }{ + {"smb://storage/Art", true}, + {"SMB://storage/Art", true}, + {`\\storage\Art`, false}, + {"/Volumes/Art", false}, + {"smb:/storage/Art", false}, + {"", false}, + } + for _, test := range tests { + if got := isSMBURL(test.path); got != test.want { + t.Errorf("isSMBURL(%q) = %v, want %v", test.path, got, test.want) + } + } +} + +func TestParseSMB(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + wantHost string + wantShare string + wantRest []string + }{ + {"plain", "smb://storage/Art/12345", "storage", "Art", []string{"12345"}}, + {"spaces", "smb://storage/Signature Coins/2026/Order 99", "storage", "Signature Coins", []string{"2026", "Order 99"}}, + {"percent encoded", "smb://storage/Signature%20Coins/2026", "storage", "Signature Coins", []string{"2026"}}, + {"share only", "smb://storage/Art", "storage", "Art", nil}, + {"trailing slash", "smb://storage/Art/", "storage", "Art", nil}, + {"user and port", "smb://brian@storage:445/Art/x", "storage", "Art", []string{"x"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + u, share, rest, err := parseSMB(test.raw) + if err != nil { + t.Fatalf("parseSMB(%q) error = %v", test.raw, err) + } + if u.Hostname() != test.wantHost { + t.Errorf("host = %q, want %q", u.Hostname(), test.wantHost) + } + if share != test.wantShare { + t.Errorf("share = %q, want %q", share, test.wantShare) + } + if !slices.Equal(rest, test.wantRest) { + t.Errorf("rest = %q, want %q", rest, test.wantRest) + } + }) + } +} + +// Invalid smb URLs must fail in parseSMB as errBadPath, well before any +// platform mount logic could run (tests must never mount a share). +func TestParseSMBInvalid(t *testing.T) { + t.Parallel() + + for _, raw := range []string{"smb://", "smb:///Art", "smb://storage", "smb://storage/"} { + if _, _, _, err := parseSMB(raw); !errors.Is(err, errBadPath) { + t.Errorf("parseSMB(%q) error = %v, want errBadPath", raw, err) + } + } + for _, raw := range []string{"smb://storage", "smb://storage/"} { + if _, err := openPath(raw); !errors.Is(err, errBadPath) { + t.Errorf("openPath(%q) error = %v, want errBadPath", raw, err) + } + } +} diff --git a/smb_windows.go b/smb_windows.go new file mode 100644 index 0000000..00c4648 --- /dev/null +++ b/smb_windows.go @@ -0,0 +1,13 @@ +package main + +import "path/filepath" + +// resolveSMB translates an smb:// URL to the equivalent UNC path, which +// Windows resolves natively (connecting and authenticating as needed). +func resolveSMB(raw string) (string, error) { + u, share, rest, err := parseSMB(raw) + if err != nil { + return "", err + } + return filepath.Join(append([]string{`\\` + u.Hostname(), share}, rest...)...), nil +}