diff --git a/cmd/receive.go b/cmd/receive.go index e533fe7..bcabefb 100644 --- a/cmd/receive.go +++ b/cmd/receive.go @@ -1,14 +1,12 @@ package cmd import ( - "encoding/binary" + "context" "fmt" - "io" - "net" "os" - "path/filepath" "time" + "github.com/neozmmv/blindspot/internal/transfer" bstun "github.com/neozmmv/blindspot/internal/tun" "github.com/neozmmv/blindspot/internal/utils" "github.com/spf13/cobra" @@ -36,77 +34,56 @@ var ReceiveCmd = &cobra.Command{ return } } else { - home, err := os.UserHomeDir() + destDir, err = transfer.DownloadsDir() if err != nil { - fmt.Println("Error finding home directory:", err) - return - } - destDir = filepath.Join(home, "Downloads") - if err := os.MkdirAll(destDir, 0755); err != nil { - fmt.Println("Error creating Downloads directory:", err) + fmt.Println(err) return } } myIP := bstun.VirtualIPv4(publicKey) - ln, err := net.Listen("tcp", myIP+transferPort) + recv, err := transfer.Listen(context.Background(), myIP) if err != nil { - fmt.Printf("Could not listen on %s: %v\n", myIP+transferPort, err) + fmt.Println(err) return } - defer ln.Close() + defer recv.Close() - fmt.Printf("Waiting for file on %s...\n", myIP+transferPort) + fmt.Printf("Waiting for file on %s...\n", recv.Addr()) - conn, err := ln.Accept() - if err != nil { - fmt.Println("Error accepting connection:", err) - return - } - defer conn.Close() - - var nameLen uint16 - if err := binary.Read(conn, binary.BigEndian, &nameLen); err != nil { - fmt.Println("Error reading filename length:", err) - return - } - nameBuf := make([]byte, nameLen) - if _, err := io.ReadFull(conn, nameBuf); err != nil { - fmt.Println("Error reading filename:", err) - return - } - - var fileSize uint64 - if err := binary.Read(conn, binary.BigEndian, &fileSize); err != nil { - fmt.Println("Error reading file size:", err) - return - } + // Render progress off a goroutine: the first snapshot prints the "Receiving…" + // header, later snapshots overwrite a single live line via carriage return. + prog := make(chan transfer.Progress, 1) + renderDone := make(chan struct{}) + go func() { + var lr transfer.LineRenderer + first := true + for p := range prog { + if first { + first = false + lr.Seed(p) + fmt.Printf("Receiving %s (%d MB) from %s...\n", p.Name, p.Total/(1024*1024), p.PeerAddr) + continue + } + fmt.Print("\r" + lr.Line(p)) + } + close(renderDone) + }() - filename := string(nameBuf) - destPath := filepath.Join(destDir, filename) - fmt.Printf("Receiving %s (%d MB) from %s...\n", filename, fileSize/(1024*1024), conn.RemoteAddr()) + res, err := recv.Accept(context.Background(), destDir, prog) + close(prog) + <-renderDone - f, err := os.Create(destPath) - if err != nil { - fmt.Println("Error creating file:", err) - return + if enteredBody(err) { + fmt.Println() } - defer f.Close() - - pw := &progressWriter{w: f} - stop := startProgress(int64(fileSize), pw) - start := time.Now() - n, err := io.CopyN(pw, conn, int64(fileSize)) - stop() - fmt.Println() if err != nil { - fmt.Printf("Error receiving file (got %d/%d bytes): %v\n", n, fileSize, err) + fmt.Println(err) return } - elapsed := time.Since(start) fmt.Printf("Saved to %s — %s in %s (avg %s/s)\n", - destPath, formatBytes(float64(n)), elapsed.Round(time.Millisecond), - formatBytes(float64(n)/elapsed.Seconds())) + res.Path, transfer.FormatBytes(float64(res.Bytes)), res.Elapsed.Round(time.Millisecond), + transfer.FormatBytes(float64(res.Bytes)/res.Elapsed.Seconds())) }, } diff --git a/cmd/send.go b/cmd/send.go index f6d8eed..341eba8 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -1,19 +1,15 @@ package cmd import ( - "encoding/binary" + "context" + "errors" "fmt" - "io" - "net" - "os" - "path/filepath" "time" + "github.com/neozmmv/blindspot/internal/transfer" "github.com/spf13/cobra" ) -const transferPort = ":28125" - var SendCmd = &cobra.Command{ Use: "send ", Short: "Send a file to a peer", @@ -22,54 +18,53 @@ var SendCmd = &cobra.Command{ peerIP := args[0] filePath := args[1] - f, err := os.Open(filePath) - if err != nil { - fmt.Println("Error opening file:", err) - return - } - defer f.Close() + // Render progress off a goroutine: the first snapshot prints the "Sending…" + // header, later snapshots overwrite a single live line via carriage return. + prog := make(chan transfer.Progress, 1) + renderDone := make(chan struct{}) + go func() { + var lr transfer.LineRenderer + first := true + for p := range prog { + if first { + first = false + lr.Seed(p) + fmt.Printf("Sending %s (%s) to %s...\n", p.Name, transfer.FormatBytes(float64(p.Total)), peerIP) + continue + } + fmt.Print("\r" + lr.Line(p)) + } + close(renderDone) + }() - info, err := f.Stat() - if err != nil { - fmt.Println("Error reading file info:", err) - return - } + res, err := transfer.Send(context.Background(), peerIP, filePath, prog) + close(prog) + <-renderDone - conn, err := net.DialTimeout("tcp", peerIP+transferPort, 5*time.Second) - if err != nil { - fmt.Printf("Peer %s is not receiving. Ask them to run 'blindspot receive'.\n", peerIP) - return + if enteredBody(err) { + fmt.Println() } - defer conn.Close() - - name := []byte(filepath.Base(filePath)) - if err := binary.Write(conn, binary.BigEndian, uint16(len(name))); err != nil { - fmt.Println("Error sending filename length:", err) - return - } - if _, err := conn.Write(name); err != nil { - fmt.Println("Error sending filename:", err) - return - } - if err := binary.Write(conn, binary.BigEndian, uint64(info.Size())); err != nil { - fmt.Println("Error sending file size:", err) - return - } - - fmt.Printf("Sending %s (%s) to %s...\n", info.Name(), formatBytes(float64(info.Size())), peerIP) - pw := &progressWriter{w: conn} - stop := startProgress(info.Size(), pw) - start := time.Now() - n, err := io.Copy(pw, f) - stop() - fmt.Println() if err != nil { - fmt.Println("Error sending file:", err) + fmt.Println(err) return } - elapsed := time.Since(start) fmt.Printf("Done — %s in %s (avg %s/s)\n", - formatBytes(float64(n)), elapsed.Round(time.Millisecond), - formatBytes(float64(n)/elapsed.Seconds())) + transfer.FormatBytes(float64(res.Bytes)), res.Elapsed.Round(time.Millisecond), + transfer.FormatBytes(float64(res.Bytes)/res.Elapsed.Seconds())) }, } + +// enteredBody reports whether a transfer got as far as streaming the file body — i.e. it +// succeeded, or failed during the copy. It gates the blank line that terminates the live +// progress display, so a failure before the body (dial, header, file create) prints no +// stray blank line, exactly as the pre-refactor CLI did. +func enteredBody(err error) bool { + if err == nil { + return true + } + var te *transfer.Error + if errors.As(err, &te) { + return te.Op == transfer.OpSendBody || te.Op == transfer.OpRecvBody + } + return false +} diff --git a/cmd/transfer.go b/cmd/transfer.go deleted file mode 100644 index a1cbecf..0000000 --- a/cmd/transfer.go +++ /dev/null @@ -1,67 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "sync/atomic" - "time" -) - -type progressWriter struct { - w io.Writer - bytes atomic.Int64 -} - -func (pw *progressWriter) Write(p []byte) (n int, err error) { - n, err = pw.w.Write(p) - pw.bytes.Add(int64(n)) - return -} - -// startProgress starts a goroutine that prints a live speed/ETA line. -// Call the returned stop func when the transfer is done. -func startProgress(total int64, pw *progressWriter) func() { - done := make(chan struct{}) - go func() { - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - var lastBytes int64 - lastTick := time.Now() - for { - select { - case <-done: - return - case t := <-ticker.C: - current := pw.bytes.Load() - delta := current - lastBytes - elapsed := t.Sub(lastTick) - lastBytes = current - lastTick = t - - speed := float64(delta) / elapsed.Seconds() - pct := float64(current) / float64(total) * 100 - var eta string - if speed > 0 { - secs := float64(total-current) / speed - eta = fmt.Sprintf("ETA %s", time.Duration(secs*float64(time.Second)).Round(time.Second)) - } else { - eta = "ETA --" - } - fmt.Printf("\r %.1f%% %s %s ", - pct, formatBytes(speed)+"/s", eta) - } - } - }() - return func() { close(done) } -} - -func formatBytes(b float64) string { - switch { - case b >= 1024*1024: - return fmt.Sprintf("%.2f MB", b/1024/1024) - case b >= 1024: - return fmt.Sprintf("%.2f KB", b/1024) - default: - return fmt.Sprintf("%.0f B", b) - } -} diff --git a/go.mod b/go.mod index 800ee13..1ac4429 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/go-ole/go-ole v1.3.0 // indirect diff --git a/go.sum b/go.sum index 5511808..193d3a6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= diff --git a/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/index.js b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/index.js index e934bff..977948e 100644 --- a/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/index.js +++ b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/index.js @@ -8,6 +8,7 @@ export { }; export { + IncomingRequest, Peer, Status } from "./models.js"; diff --git a/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/models.js b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/models.js index 2569f50..d59bf83 100644 --- a/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/models.js +++ b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/models.js @@ -6,6 +6,59 @@ // @ts-ignore: Unused imports import { Create as $Create } from "@wailsio/runtime"; +/** + * IncomingRequest is a pending inbound file offer surfaced to the frontend as an + * Accept/Decline card. It mirrors the native toast. + */ +export class IncomingRequest { + /** + * Creates a new IncomingRequest instance. + * @param {Partial} [$$source = {}] - The source object to create the IncomingRequest. + */ + constructor($$source = {}) { + if (!("id" in $$source)) { + /** + * @member + * @type {string} + */ + this["id"] = ""; + } + if (!("peerIP" in $$source)) { + /** + * @member + * @type {string} + */ + this["peerIP"] = ""; + } + if (!("filename" in $$source)) { + /** + * @member + * @type {string} + */ + this["filename"] = ""; + } + if (!("size" in $$source)) { + /** + * @member + * @type {string} + */ + this["size"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new IncomingRequest instance from a string or object. + * @param {any} [$$source = {}] + * @returns {IncomingRequest} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new IncomingRequest(/** @type {Partial} */($$parsedSource)); + } +} + /** * Peer is one connected member of the active session. */ @@ -107,6 +160,31 @@ export class Status { */ this["transfer"] = ""; } + if (!("awaitingAccept" in $$source)) { + /** + * Consent handshake (see requests.go). + * sender: waiting for a peer to accept + * @member + * @type {boolean} + */ + this["awaitingAccept"] = false; + } + if (!("awaitingPeer" in $$source)) { + /** + * sender: peer we're waiting on + * @member + * @type {string} + */ + this["awaitingPeer"] = ""; + } + if (!("incoming" in $$source)) { + /** + * receiver: pending inbound request, if any + * @member + * @type {IncomingRequest | null} + */ + this["incoming"] = null; + } Object.assign(this, $$source); } @@ -118,10 +196,14 @@ export class Status { */ static createFrom($$source = {}) { const $$createField3_0 = $$createType1; + const $$createField9_0 = $$createType3; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("peers" in $$parsedSource) { $$parsedSource["peers"] = $$createField3_0($$parsedSource["peers"]); } + if ("incoming" in $$parsedSource) { + $$parsedSource["incoming"] = $$createField9_0($$parsedSource["incoming"]); + } return new Status(/** @type {Partial} */($$parsedSource)); } } @@ -129,3 +211,5 @@ export class Status { // Private type creation functions const $$createType0 = Peer.createFrom; const $$createType1 = $Create.Array($$createType0); +const $$createType2 = IncomingRequest.createFrom; +const $$createType3 = $Create.Nullable($$createType2); diff --git a/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/trayservice.js b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/trayservice.js index 3018255..50117ea 100644 --- a/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/trayservice.js +++ b/internal/gui/frontend/bindings/github.com/neozmmv/blindspot/internal/gui/trayservice.js @@ -3,11 +3,10 @@ // This file is automatically generated. DO NOT EDIT /** - * TrayService is the bridge exposed to the frontend. It wraps the same commands a - * user would run in a terminal — shelling out to the blindspot binary for the - * operations that need privilege elevation or long-running transfer logic - * (connect / send / receive), and reading the on-disk session state directly for - * everything else. + * TrayService is the bridge exposed to the frontend. It shells out to the blindspot + * binary for connect (which needs privilege elevation) and calls the in-process + * internal/transfer package directly for file send/receive, while reading the on-disk + * session state directly for status and disconnect. * @module */ @@ -20,13 +19,35 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr import * as $models from "./models.js"; /** - * CancelReceive kills a pending `receive` process, if one is running. + * AcceptRequest / DeclineRequest are invoked from the frontend panel buttons and from + * the notification-response callback. Both deliver on the pending request's decision + * channel; a late call (after timeout/cancel) is a harmless no-op. + * @param {string} id + * @returns {$CancellablePromise} + */ +export function AcceptRequest(id) { + return $Call.ByID(1028681348, id); +} + +/** + * CancelReceive cancels a pending receive, if one is running. Cancelling the context + * unblocks the in-process Accept, whose goroutine then clears the receive state. * @returns {$CancellablePromise} */ export function CancelReceive() { return $Call.ByID(3816120566); } +/** + * CancelSend aborts a pending outbound offer. The cancelled flag is set before the conn + * is closed so the blocked response read observes it and clears silently instead of + * reporting an error; closing the conn also makes the receiver's prompt dismiss (EOF). + * @returns {$CancellablePromise} + */ +export function CancelSend() { + return $Call.ByID(3939382243); +} + /** * Connect runs `blindspot connect -s -p [-n] [-H ]`, * which triggers the UAC elevation + daemon launch and blocks until the session is @@ -42,6 +63,14 @@ export function Connect(session, password, isNew, hostname) { return $Call.ByID(1698713851, session, password, isNew, hostname); } +/** + * @param {string} id + * @returns {$CancellablePromise} + */ +export function DeclineRequest(id) { + return $Call.ByID(4275373592, id); +} + /** * Disconnect signals the running daemon to stop (the same mechanism as * `blindspot disconnect`) and waits briefly for it to tear down. @@ -81,8 +110,12 @@ export function SelectFile() { } /** - * SendFile runs `blindspot send ` and returns the CLI's final - * line. Blocks for the duration of the transfer. + * SendFile offers a file to a peer. It first asks the peer's control channel (:28126) + * for consent; on accept it streams the file over :28125. If the peer has no control + * listener (an older tray) it falls back to a direct send so mixed versions interoperate. + * Blocks until the peer decides (or a timeout) and, on accept, for the transfer. A + * failure is surfaced as the status line and returned with a nil error, matching the + * prior behavior where transfer failures were reported without an exception. * @param {string} peerIP * @param {string} filePath * @returns {$CancellablePromise} @@ -92,9 +125,11 @@ export function SendFile(peerIP, filePath) { } /** - * StartReceive launches `blindspot receive` in the background so the tray can keep - * serving while it waits for an incoming file. Progress lines are streamed into the - * transfer status and pushed to the frontend. + * StartReceive binds an in-process receive listener so the tray can keep serving while + * it waits for an incoming file. Returning without error means the port is bound. A + * goroutine runs the one-shot Accept, streaming progress into the transfer status and + * pushing it to the frontend; when it completes the receive state clears so a later + * StartReceive rebinds cleanly. * @param {boolean} here * @returns {$CancellablePromise} */ diff --git a/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.js b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.js new file mode 100644 index 0000000..64aee3e --- /dev/null +++ b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.js @@ -0,0 +1,17 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as NotificationService from "./notificationservice.js"; +export { + NotificationService +}; + +export { + NotificationAction, + NotificationAttachment, + NotificationCategory, + NotificationOptions, + NotificationSchedule, + NotificationSound +} from "./models.js"; diff --git a/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.js b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.js new file mode 100644 index 0000000..d51c6bb --- /dev/null +++ b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.js @@ -0,0 +1,398 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +/** + * NotificationAction represents an action button for a notification. + */ +export class NotificationAction { + /** + * Creates a new NotificationAction instance. + * @param {Partial} [$$source = {}] - The source object to create the NotificationAction. + */ + constructor($$source = {}) { + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["id"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["title"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * (macOS-specific) + * @member + * @type {boolean | undefined} + */ + this["destructive"] = undefined; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationAction instance from a string or object. + * @param {any} [$$source = {}] + * @returns {NotificationAction} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new NotificationAction(/** @type {Partial} */($$parsedSource)); + } +} + +/** + * NotificationAttachment is a media file shown with the notification. + * Path is an absolute filesystem path (or "file://" URL on macOS). + */ +export class NotificationAttachment { + /** + * Creates a new NotificationAttachment instance. + * @param {Partial} [$$source = {}] - The source object to create the NotificationAttachment. + */ + constructor($$source = {}) { + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["id"] = undefined; + } + if (!("path" in $$source)) { + /** + * @member + * @type {string} + */ + this["path"] = ""; + } + if (/** @type {any} */(false)) { + /** + * Type is an optional placement/UTI hint. + * On macOS: a UTI like "public.png" / "public.audio" (often inferred). + * On Windows: "hero" | "appLogoOverride" | "inline" (default "inline"). + * On Linux: ignored (always image-path hint). + * @member + * @type {string | undefined} + */ + this["type"] = undefined; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationAttachment instance from a string or object. + * @param {any} [$$source = {}] + * @returns {NotificationAttachment} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new NotificationAttachment(/** @type {Partial} */($$parsedSource)); + } +} + +/** + * NotificationCategory groups actions for notifications. + */ +export class NotificationCategory { + /** + * Creates a new NotificationCategory instance. + * @param {Partial} [$$source = {}] - The source object to create the NotificationCategory. + */ + constructor($$source = {}) { + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["id"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {NotificationAction[] | undefined} + */ + this["actions"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {boolean | undefined} + */ + this["hasReplyField"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["replyPlaceholder"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["replyButtonTitle"] = undefined; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationCategory instance from a string or object. + * @param {any} [$$source = {}] + * @returns {NotificationCategory} + */ + static createFrom($$source = {}) { + const $$createField1_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("actions" in $$parsedSource) { + $$parsedSource["actions"] = $$createField1_0($$parsedSource["actions"]); + } + return new NotificationCategory(/** @type {Partial} */($$parsedSource)); + } +} + +/** + * NotificationOptions contains configuration for a notification. + * + * New optional fields (Sound, Attachments, ThreadID, InterruptionLevel, + * Schedule) gracefully degrade when a platform cannot honour them; see the + * package-level godoc for the per-platform support matrix. + */ +export class NotificationOptions { + /** + * Creates a new NotificationOptions instance. + * @param {Partial} [$$source = {}] - The source object to create the NotificationOptions. + */ + constructor($$source = {}) { + if (!("id" in $$source)) { + /** + * @member + * @type {string} + */ + this["id"] = ""; + } + if (!("title" in $$source)) { + /** + * @member + * @type {string} + */ + this["title"] = ""; + } + if (/** @type {any} */(false)) { + /** + * (macOS and Linux only) + * @member + * @type {string | undefined} + */ + this["subtitle"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["body"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["categoryId"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {{ [_ in string]?: any } | undefined} + */ + this["data"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * Sound controls the sound played on delivery. + * nil -> platform default sound + * &NotificationSound{Silent: true} -> no sound + * &NotificationSound{Name: "Ping"} -> named/bundled sound + * On macOS, Name is resolved by [UNNotificationSound soundNamed:] and + * requires the audio file to live under the bundle's Library/Sounds. + * On Windows, Name is used as-is if it begins with "ms-winsoundevent:" or + * "ms-appx:"; otherwise it is wrapped in "ms-winsoundevent:" for built-in + * event names. On Linux it is forwarded as the freedesktop "sound-name" + * hint (theme-dependent). + * @member + * @type {NotificationSound | null | undefined} + */ + this["sound"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * Attachments are media files shown alongside the notification. macOS + * supports multiple attachments of any media type; Windows and Linux + * honour the first image-typed attachment (Linux limits to one per spec). + * @member + * @type {NotificationAttachment[] | undefined} + */ + this["attachments"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * ThreadID groups related notifications together in Notification Center + * (macOS) / Action Center (Windows) / the notification daemon (Linux). + * @member + * @type {string | undefined} + */ + this["threadId"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * InterruptionLevel controls notification priority. One of "passive", + * "active" (default), "timeSensitive", "critical". Critical requires + * macOS 12+ and the Critical Alert entitlement. Linux maps to the + * freedesktop urgency hint; Windows maps to . + * @member + * @type {string | undefined} + */ + this["interruptionLevel"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * Schedule defers delivery. macOS uses a native trigger and persists + * across app restarts. Windows and Linux fall back to an in-process + * time.AfterFunc timer that does NOT survive an app exit. + * @member + * @type {NotificationSchedule | null | undefined} + */ + this["schedule"] = undefined; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationOptions instance from a string or object. + * @param {any} [$$source = {}] + * @returns {NotificationOptions} + */ + static createFrom($$source = {}) { + const $$createField5_0 = $$createType2; + const $$createField6_0 = $$createType4; + const $$createField7_0 = $$createType6; + const $$createField10_0 = $$createType8; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("data" in $$parsedSource) { + $$parsedSource["data"] = $$createField5_0($$parsedSource["data"]); + } + if ("sound" in $$parsedSource) { + $$parsedSource["sound"] = $$createField6_0($$parsedSource["sound"]); + } + if ("attachments" in $$parsedSource) { + $$parsedSource["attachments"] = $$createField7_0($$parsedSource["attachments"]); + } + if ("schedule" in $$parsedSource) { + $$parsedSource["schedule"] = $$createField10_0($$parsedSource["schedule"]); + } + return new NotificationOptions(/** @type {Partial} */($$parsedSource)); + } +} + +/** + * NotificationSchedule defers delivery. Exactly one of DelaySeconds or At + * must be set. At is interpreted as Unix seconds (UTC). + */ +export class NotificationSchedule { + /** + * Creates a new NotificationSchedule instance. + * @param {Partial} [$$source = {}] - The source object to create the NotificationSchedule. + */ + constructor($$source = {}) { + if (/** @type {any} */(false)) { + /** + * @member + * @type {number | undefined} + */ + this["delaySeconds"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {number | undefined} + */ + this["at"] = undefined; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationSchedule instance from a string or object. + * @param {any} [$$source = {}] + * @returns {NotificationSchedule} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new NotificationSchedule(/** @type {Partial} */($$parsedSource)); + } +} + +/** + * NotificationSound configures audio playback for a notification. + */ +export class NotificationSound { + /** + * Creates a new NotificationSound instance. + * @param {Partial} [$$source = {}] - The source object to create the NotificationSound. + */ + constructor($$source = {}) { + if (/** @type {any} */(false)) { + /** + * @member + * @type {boolean | undefined} + */ + this["silent"] = undefined; + } + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["name"] = undefined; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationSound instance from a string or object. + * @param {any} [$$source = {}] + * @returns {NotificationSound} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new NotificationSound(/** @type {Partial} */($$parsedSource)); + } +} + +// Private type creation functions +const $$createType0 = NotificationAction.createFrom; +const $$createType1 = $Create.Array($$createType0); +const $$createType2 = $Create.Map($Create.Any, $Create.Any); +const $$createType3 = NotificationSound.createFrom; +const $$createType4 = $Create.Nullable($$createType3); +const $$createType5 = NotificationAttachment.createFrom; +const $$createType6 = $Create.Array($$createType5); +const $$createType7 = NotificationSchedule.createFrom; +const $$createType8 = $Create.Nullable($$createType7); diff --git a/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.js b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.js new file mode 100644 index 0000000..5530a3a --- /dev/null +++ b/internal/gui/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.js @@ -0,0 +1,113 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Service represents the notifications service + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * @returns {$CancellablePromise} + */ +export function CheckNotificationAuthorization() { + return $Call.ByID(2216952893); +} + +/** + * @param {$models.NotificationCategory} category + * @returns {$CancellablePromise} + */ +export function RegisterNotificationCategory(category) { + return $Call.ByID(2917562919, category); +} + +/** + * @returns {$CancellablePromise} + */ +export function RemoveAllDeliveredNotifications() { + return $Call.ByID(3956282340); +} + +/** + * @returns {$CancellablePromise} + */ +export function RemoveAllPendingNotifications() { + return $Call.ByID(108821341); +} + +/** + * @param {string} identifier + * @returns {$CancellablePromise} + */ +export function RemoveDeliveredNotification(identifier) { + return $Call.ByID(975691940, identifier); +} + +/** + * @param {string} identifier + * @returns {$CancellablePromise} + */ +export function RemoveNotification(identifier) { + return $Call.ByID(3966653866, identifier); +} + +/** + * @param {string} categoryID + * @returns {$CancellablePromise} + */ +export function RemoveNotificationCategory(categoryID) { + return $Call.ByID(2032615554, categoryID); +} + +/** + * @param {string} identifier + * @returns {$CancellablePromise} + */ +export function RemovePendingNotification(identifier) { + return $Call.ByID(3729049703, identifier); +} + +/** + * Public methods that delegate to the implementation. + * @returns {$CancellablePromise} + */ +export function RequestNotificationAuthorization() { + return $Call.ByID(3933442950); +} + +/** + * @param {$models.NotificationOptions} options + * @returns {$CancellablePromise} + */ +export function SendNotification(options) { + return $Call.ByID(3968228732, options); +} + +/** + * @param {$models.NotificationOptions} options + * @returns {$CancellablePromise} + */ +export function SendNotificationWithActions(options) { + return $Call.ByID(1886542847, options); +} + +/** + * UpdateNotification updates an in-flight notification by ID. On macOS this + * is auto-deduplicated by UNUserNotificationCenter; on Linux it uses the + * D-Bus replaces_id parameter. On Windows it currently redelivers as a new + * notification (true replace requires upstream wintoast support for tag/group). + * @param {$models.NotificationOptions} options + * @returns {$CancellablePromise} + */ +export function UpdateNotification(options) { + return $Call.ByID(461019183, options); +} diff --git a/internal/gui/frontend/public/style.css b/internal/gui/frontend/public/style.css index a4590c4..4497d76 100644 --- a/internal/gui/frontend/public/style.css +++ b/internal/gui/frontend/public/style.css @@ -303,9 +303,30 @@ button { font-family: inherit; } border-radius: 50%; animation: spin 0.7s linear infinite; } -.transfer-text { font-size: 12.5px; color: var(--text); font-variant-numeric: tabular-nums; word-break: break-word; } +.transfer-text { font-size: 12.5px; color: var(--text); font-variant-numeric: tabular-nums; word-break: break-word; flex: 1; } +.transfer-cancel { + flex-shrink: 0; + padding: 5px 11px; + border-radius: 7px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + color: var(--text-2); + background: var(--surface); + border: 1px solid var(--border); + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.transfer-cancel:hover { color: var(--danger); border-color: rgba(239, 68, 68, 0.4); background: var(--danger-soft); } @keyframes spin { to { transform: rotate(360deg); } } +/* ── incoming file request (consent) ─────────────────── */ +.request-card { display: flex; flex-direction: column; gap: 11px; border-color: var(--blue-ring); background: var(--blue-soft); } +.request-title { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; color: var(--text); } +.request-body { margin: 0; font-size: 12.5px; color: var(--text-2); line-height: 1.5; word-break: break-word; } +.request-body strong { color: var(--text); font-weight: 600; } +.request-size { color: var(--text-3); } +.request-actions { display: flex; gap: 9px; } + /* ── footer actions ──────────────────────────────────── */ .footer { display: flex; gap: 10px; margin-top: auto; padding-top: 4px; } .btn { diff --git a/internal/gui/frontend/src/App.tsx b/internal/gui/frontend/src/App.tsx index 1379a94..7774dab 100644 --- a/internal/gui/frontend/src/App.tsx +++ b/internal/gui/frontend/src/App.tsx @@ -7,6 +7,13 @@ interface Peer { publicAddr: string } +interface IncomingRequest { + id: string + peerIP: string + filename: string + size: string +} + interface Status { connected: boolean myIP: string @@ -15,6 +22,9 @@ interface Status { busy: boolean receiving: boolean transfer: string + awaitingAccept: boolean + awaitingPeer: string + incoming: IncomingRequest | null } const emptyStatus: Status = { @@ -25,6 +35,9 @@ const emptyStatus: Status = { busy: false, receiving: false, transfer: '', + awaitingAccept: false, + awaitingPeer: '', + incoming: null, } /* Minimal line icons, sized by the surrounding font. */ @@ -153,6 +166,16 @@ function App() { try { await TrayService.CancelReceive() } catch { /* ignore */ } } + const acceptRequest = async (id: string) => { + try { await TrayService.AcceptRequest(id) } catch { /* ignore */ } + } + const declineRequest = async (id: string) => { + try { await TrayService.DeclineRequest(id) } catch { /* ignore */ } + } + const cancelSend = async () => { + try { await TrayService.CancelSend() } catch { /* ignore */ } + } + return (
@@ -182,6 +205,20 @@ function App() {
+ {status.incoming && ( +
+
Incoming file
+

+ {status.incoming.peerIP} wants to send you{' '} + {status.incoming.filename} ({status.incoming.size}) +

+
+ + +
+
+ )} +

Peers

@@ -232,6 +269,9 @@ function App() {
{status.transfer} + {status.awaitingAccept && ( + + )}
)} diff --git a/internal/gui/main.go b/internal/gui/main.go index e86d6b2..ef3215c 100644 --- a/internal/gui/main.go +++ b/internal/gui/main.go @@ -9,6 +9,7 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/icons" + "github.com/wailsapp/wails/v3/pkg/services/notifications" ) //go:embed all:frontend/dist @@ -30,6 +31,9 @@ func init() { func Run() { tray := &TrayService{} + // Native notification service for the tray↔tray file-consent prompts. + notifSvc := notifications.New() + // Declared up front so the SingleInstance callback below (and the tray menu) can // close over them; assigned once the app exists. The tray is captured too so those // paths open the panel anchored to the tray icon (ShowWindow) rather than letting @@ -42,6 +46,7 @@ func Run() { Description: "P2P Toolkit: VPN, File Sharing, Chat, and More", Services: []application.Service{ application.NewService(tray), + application.NewService(notifSvc), }, Assets: application.AssetOptions{ Handler: application.AssetFileServerFS(assets), @@ -65,19 +70,40 @@ func Run() { systemTray = app.SystemTray.New() + // Wire the tray to notifications: it sends file-consent toasts, and a toast action + // (or a body click) routes back here. showWindow pops the panel anchored to the tray. + tray.notifSvc = notifSvc + tray.showWindow = systemTray.ShowWindow + notifSvc.OnNotificationResponse(func(result notifications.NotificationResult) { + if result.Error != nil { + return + } + switch result.Response.ActionIdentifier { + case actionAccept: + tray.AcceptRequest(result.Response.ID) + case actionDecline: + tray.DeclineRequest(result.Response.ID) + default: + // Body click (DEFAULT_ACTION): toast button callbacks aren't always + // delivered when the app isn't foregrounded, so land the user on the + // in-panel Accept/Decline card instead. + systemTray.ShowWindow() + } + }) + // quitting flips true only when the user chooses Quit, so the close hook below // knows to let the app actually terminate instead of just hiding the window. quitting := false window = app.Window.NewWithOptions(application.WebviewWindowOptions{ - Title: "Blindspot", - Width: 400, - Height: 600, - Frameless: true, - AlwaysOnTop: true, - Hidden: true, - DisableResize: true, - HideOnEscape: true, + Title: "Blindspot", + Width: 400, + Height: 600, + Frameless: true, + AlwaysOnTop: true, + Hidden: true, + DisableResize: true, + HideOnEscape: true, // Stay open when the user alt-tabs or clicks away — the panel only hides via // the minimize button, Escape, the tray icon, or clicking outside is a no-op. // It keeps AlwaysOnTop so it floats above other windows while visible. @@ -154,6 +180,8 @@ func Run() { // having to poll over the bindings. go func() { for { + // Keep the file-consent control listener bound while connected. + tray.ensureRequestListener() app.Event.Emit("status", tray.GetStatus()) time.Sleep(2 * time.Second) } diff --git a/internal/gui/requests.go b/internal/gui/requests.go new file mode 100644 index 0000000..dc03110 --- /dev/null +++ b/internal/gui/requests.go @@ -0,0 +1,332 @@ +package gui + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "time" + + "github.com/neozmmv/blindspot/internal/transfer" + "github.com/wailsapp/wails/v3/pkg/services/notifications" +) + +// The consent handshake runs tray ↔ tray on a control channel separate from the +// transfer port. When a sender offers a file and the receiver is not already listening, +// the receiver is prompted (native toast + in-panel card); on accept it starts a +// receiver and the transfer proceeds on :28125. +// +// Wire protocol on :28126 (sender → receiver): uint16 nameLen, name, uint64 size, then +// the sender blocks reading a single response byte — 1 = accept, 0 = decline. The open +// connection is the "on hold" state; if the sender closes it early (cancel), the +// receiver reading the conn sees EOF and dismisses the prompt. +const ( + requestPort = ":28126" + requestTimeout = 45 * time.Second + + // notifCategory groups the Yes/No actions for the incoming-file toast. + notifCategory = "file-request" + actionAccept = "accept" + actionDecline = "decline" + headerReadGrace = 5 * time.Second +) + +// inboundRequest is a pending incoming file offer awaiting the user's decision. +type inboundRequest struct { + id string + peerIP string + filename string + size string // human-readable, via transfer.FormatBytes + decision chan bool // buffered(1); true = accept, false = decline +} + +// ensureRequestListener keeps the control-channel listener bound to myIP:28126 exactly +// when the session is running. Called from the status tick (a single goroutine), so it +// need not guard against concurrent invocation — only against the fields it shares with +// the request goroutines. Binding is non-elevated. +func (s *TrayService) ensureRequestListener() { + running := s.sessionRunning() + ip := s.MyIP() + + s.mu.Lock() + ln := s.reqLn + boundIP := s.reqBoundIP + s.mu.Unlock() + + // Tear the listener down when disconnected, or when the virtual IP changed. + if ln != nil && (!running || ip == "" || boundIP != ip) { + s.mu.Lock() + s.reqLn = nil + s.reqBoundIP = "" + s.mu.Unlock() + ln.Close() + ln = nil + } + if !running || ip == "" || ln != nil { + return + } + + newLn, err := net.Listen("tcp", ip+requestPort) + if err != nil { + return // transient (e.g. IP not up yet) — retry next tick + } + s.mu.Lock() + s.reqLn = newLn + s.reqBoundIP = ip + s.mu.Unlock() + go s.acceptRequests(newLn) +} + +// acceptRequests serves the control listener until it is closed (on disconnect/rebind). +func (s *TrayService) acceptRequests(ln net.Listener) { + for { + conn, err := ln.Accept() + if err != nil { + return // listener closed + } + go s.handleRequestConn(conn) + } +} + +// handleRequestConn processes one inbound offer: read the header, apply the auto-accept +// and single-flight rules, otherwise prompt and wait for a decision, sender cancel, or +// timeout. +func (s *TrayService) handleRequestConn(conn net.Conn) { + // Bounded header read so a client that connects and sends nothing can't pin the slot. + conn.SetReadDeadline(time.Now().Add(headerReadGrace)) + name, size, err := readRequestHeader(conn) + if err != nil { + conn.Close() + return + } + conn.SetReadDeadline(time.Time{}) + + peerIP := ipOnly(conn.RemoteAddr()) + + s.mu.Lock() + receiverActive := s.receiver != nil + alreadyPending := s.pendingIn != nil + s.mu.Unlock() + + // Already receiving → accept without a prompt (today's behavior). + if receiverActive { + writeDecision(conn, true) + conn.Close() + return + } + // Single-flight for v1: a second concurrent offer is declined immediately. + if alreadyPending { + writeDecision(conn, false) + conn.Close() + return + } + + req := &inboundRequest{ + id: s.nextRequestID(peerIP), + peerIP: peerIP, + filename: name, + size: transfer.FormatBytes(float64(size)), + decision: make(chan bool, 1), + } + s.mu.Lock() + s.pendingIn = req + s.mu.Unlock() + s.emit() + if s.showWindow != nil { + s.showWindow() + } + s.sendRequestNotification(req) + + // Detect an early sender close (cancel) while we wait for the decision. + senderGone := make(chan struct{}) + go func() { + var b [1]byte + if _, err := conn.Read(b[:]); err != nil { + close(senderGone) + } + }() + + select { + case accepted := <-req.decision: + s.removeNotification(req.id) + if !accepted { + writeDecision(conn, false) + s.clearPendingIn(req.id) + conn.Close() + return + } + // If the sender already cancelled (closed the conn) before the click landed, + // don't bother starting a receiver. + select { + case <-senderGone: + s.clearPendingIn(req.id) + conn.Close() + return + default: + } + // Accept: start a receiver, then signal readiness. Because startReceiver returns + // only once the port is bound, the accept byte is safe to write immediately after. + if err := s.startReceiver(false); err != nil { + // e.g. an external `blindspot receive` already holds the port — decline cleanly. + writeDecision(conn, false) + s.clearPendingIn(req.id) + conn.Close() + return + } + // Cancel-race guard: if writing the accept fails, the sender vanished between the + // check above and here, so it will never dial :28125 — tear the receiver back down + // rather than leave it listening forever. + if err := writeDecision(conn, true); err != nil { + s.CancelReceive() + } + s.clearPendingIn(req.id) + conn.Close() + + case <-senderGone: + s.removeNotification(req.id) + s.clearPendingIn(req.id) + conn.Close() + + case <-time.After(requestTimeout): + writeDecision(conn, false) + s.removeNotification(req.id) + s.clearPendingIn(req.id) + conn.Close() + } +} + +// AcceptRequest / DeclineRequest are invoked from the frontend panel buttons and from +// the notification-response callback. Both deliver on the pending request's decision +// channel; a late call (after timeout/cancel) is a harmless no-op. +func (s *TrayService) AcceptRequest(id string) error { + s.deliverDecision(id, true) + return nil +} + +func (s *TrayService) DeclineRequest(id string) error { + s.deliverDecision(id, false) + return nil +} + +func (s *TrayService) deliverDecision(id string, accept bool) { + s.mu.Lock() + req := s.pendingIn + s.mu.Unlock() + if req == nil || req.id != id { + return + } + select { + case req.decision <- accept: + default: // already decided/cancelled + } +} + +// CancelSend aborts a pending outbound offer. The cancelled flag is set before the conn +// is closed so the blocked response read observes it and clears silently instead of +// reporting an error; closing the conn also makes the receiver's prompt dismiss (EOF). +func (s *TrayService) CancelSend() error { + s.mu.Lock() + conn := s.pendingOutConn + if conn != nil { + s.sendCancelled = true + } + s.mu.Unlock() + if conn != nil { + conn.Close() + } + return nil +} + +func (s *TrayService) clearPendingIn(id string) { + s.mu.Lock() + if s.pendingIn != nil && s.pendingIn.id == id { + s.pendingIn = nil + } + s.mu.Unlock() + s.emit() +} + +func (s *TrayService) nextRequestID(peerIP string) string { + s.mu.Lock() + s.reqCounter++ + n := s.reqCounter + s.mu.Unlock() + return fmt.Sprintf("%s-%d", peerIP, n) +} + +// sendRequestNotification pushes the native toast with Yes/No actions. The category is +// registered lazily on first use, safely after the notification service has started. +func (s *TrayService) sendRequestNotification(req *inboundRequest) { + if s.notifSvc == nil { + return + } + s.catOnce.Do(func() { + s.notifSvc.RequestNotificationAuthorization() + s.notifSvc.RegisterNotificationCategory(notifications.NotificationCategory{ + ID: notifCategory, + Actions: []notifications.NotificationAction{ + {ID: actionAccept, Title: "Accept"}, + {ID: actionDecline, Title: "Decline"}, + }, + }) + }) + s.notifSvc.SendNotificationWithActions(notifications.NotificationOptions{ + ID: req.id, + Title: "Incoming file", + Body: fmt.Sprintf("%s wants to send you %s (%s)", req.peerIP, req.filename, req.size), + CategoryID: notifCategory, + Data: map[string]any{"requestID": req.id}, + }) +} + +func (s *TrayService) removeNotification(id string) { + if s.notifSvc != nil { + s.notifSvc.RemoveNotification(id) + } +} + +// --- control-channel framing (shared shape with the transfer header, distinct port) --- + +func writeRequestHeader(conn net.Conn, name string, size uint64) error { + nb := []byte(name) + if err := binary.Write(conn, binary.BigEndian, uint16(len(nb))); err != nil { + return err + } + if _, err := conn.Write(nb); err != nil { + return err + } + return binary.Write(conn, binary.BigEndian, size) +} + +func readRequestHeader(conn net.Conn) (name string, size uint64, err error) { + var nameLen uint16 + if err = binary.Read(conn, binary.BigEndian, &nameLen); err != nil { + return "", 0, err + } + nb := make([]byte, nameLen) + if _, err = io.ReadFull(conn, nb); err != nil { + return "", 0, err + } + if err = binary.Read(conn, binary.BigEndian, &size); err != nil { + return "", 0, err + } + return string(nb), size, nil +} + +func writeDecision(conn net.Conn, accept bool) error { + b := byte(0) + if accept { + b = 1 + } + _, err := conn.Write([]byte{b}) + return err +} + +// ipOnly strips the port from a net.Addr, yielding the peer's virtual IP. +func ipOnly(a net.Addr) string { + host, _, err := net.SplitHostPort(a.String()) + if err != nil { + return a.String() + } + return host +} diff --git a/internal/gui/requests_test.go b/internal/gui/requests_test.go new file mode 100644 index 0000000..815afa5 --- /dev/null +++ b/internal/gui/requests_test.go @@ -0,0 +1,66 @@ +package gui + +import ( + "io" + "net" + "testing" +) + +// TestRequestHeaderRoundTrip locks the :28126 control-channel framing so it can't drift +// between tray versions (both ends must agree for the consent handshake to interoperate). +func TestRequestHeaderRoundTrip(t *testing.T) { + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + + const name = "quarterly report.pdf" + const size uint64 = 9_400_123 + + errc := make(chan error, 1) + go func() { errc <- writeRequestHeader(c1, name, size) }() + + gotName, gotSize, err := readRequestHeader(c2) + if err != nil { + t.Fatalf("readRequestHeader: %v", err) + } + if werr := <-errc; werr != nil { + t.Fatalf("writeRequestHeader: %v", werr) + } + if gotName != name || gotSize != size { + t.Fatalf("round-trip = (%q, %d), want (%q, %d)", gotName, gotSize, name, size) + } +} + +// TestDecisionByte confirms the accept/decline response is a single 1/0 byte. +func TestDecisionByte(t *testing.T) { + for _, accept := range []bool{true, false} { + c1, c2 := net.Pipe() + go func() { + writeDecision(c1, accept) + c1.Close() + }() + buf := make([]byte, 1) + if _, err := io.ReadFull(c2, buf); err != nil { + t.Fatalf("accept=%v: read: %v", accept, err) + } + want := byte(0) + if accept { + want = 1 + } + if buf[0] != want { + t.Fatalf("accept=%v: got byte %d, want %d", accept, buf[0], want) + } + c2.Close() + } +} + +// TestIPOnly checks the virtual-IP extraction used to attribute a request to a peer. +func TestIPOnly(t *testing.T) { + addr, err := net.ResolveTCPAddr("tcp", "10.5.6.7:53000") + if err != nil { + t.Fatal(err) + } + if got := ipOnly(addr); got != "10.5.6.7" { + t.Fatalf("ipOnly = %q, want 10.5.6.7", got) + } +} diff --git a/internal/gui/trayservice.go b/internal/gui/trayservice.go index 77cc516..72d5f2f 100644 --- a/internal/gui/trayservice.go +++ b/internal/gui/trayservice.go @@ -1,9 +1,12 @@ package gui import ( - "bufio" + "context" "encoding/json" + "errors" "fmt" + "io" + "net" "os" "os/exec" "path/filepath" @@ -11,10 +14,13 @@ import ( "strconv" "strings" "sync" + "syscall" "time" "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + "github.com/neozmmv/blindspot/internal/transfer" bstun "github.com/neozmmv/blindspot/internal/tun" "github.com/neozmmv/blindspot/internal/utils" ) @@ -36,25 +42,53 @@ type Peer struct { type Status struct { Connected bool `json:"connected"` MyIP string `json:"myIP"` - Session string `json:"session"` // active session name, if this tray started it + Session string `json:"session"` // active session name, if this tray started it Peers []Peer `json:"peers"` Busy bool `json:"busy"` // a connect/send is in flight Receiving bool `json:"receiving"` // a receive listener is running Transfer string `json:"transfer"` // latest file-transfer status line + + // Consent handshake (see requests.go). + AwaitingAccept bool `json:"awaitingAccept"` // sender: waiting for a peer to accept + AwaitingPeer string `json:"awaitingPeer"` // sender: peer we're waiting on + Incoming *IncomingRequest `json:"incoming"` // receiver: pending inbound request, if any +} + +// IncomingRequest is a pending inbound file offer surfaced to the frontend as an +// Accept/Decline card. It mirrors the native toast. +type IncomingRequest struct { + ID string `json:"id"` + PeerIP string `json:"peerIP"` + Filename string `json:"filename"` + Size string `json:"size"` } -// TrayService is the bridge exposed to the frontend. It wraps the same commands a -// user would run in a terminal — shelling out to the blindspot binary for the -// operations that need privilege elevation or long-running transfer logic -// (connect / send / receive), and reading the on-disk session state directly for -// everything else. +// TrayService is the bridge exposed to the frontend. It shells out to the blindspot +// binary for connect (which needs privilege elevation) and calls the in-process +// internal/transfer package directly for file send/receive, while reading the on-disk +// session state directly for status and disconnect. type TrayService struct { - mu sync.Mutex - busy bool - transfer string - session string // the session name this tray connected to, if any - recvCmd *exec.Cmd // running `receive` process, if any - recvCancelled bool // set when the user stops the receive, so we clear rather than report + mu sync.Mutex + busy bool + transfer string + session string // the session name this tray connected to, if any + receiver *transfer.Receiver // in-process receive listener, while a receive is running + recvCancel context.CancelFunc // cancels the running receive + + // Consent handshake state (see requests.go). All fields below are guarded by mu. + reqLn net.Listener // control-channel listener on myIP:28126 while connected + reqBoundIP string // the IP reqLn is bound to (to detect a virtual-IP change) + pendingIn *inboundRequest // at most one pending inbound request (single-flight) + reqCounter uint64 // monotonic source for request IDs + + pendingOutConn net.Conn // sender: open control conn while awaiting a decision + awaitingPeer string // sender: peer IP we're waiting on (empty when not awaiting) + sendCancelled bool // sender: set by CancelSend before it closes pendingOutConn + + // Injected once at startup (before app.Run), read-only thereafter. + notifSvc *notifications.NotificationService + showWindow func() // pops the tray panel to the front + catOnce sync.Once } // cliExe returns the path to the blindspot CLI binary the tray shells out to. The @@ -131,19 +165,32 @@ func (s *TrayService) GetStatus() Status { peers = []Peer{} } s.mu.Lock() - busy, transfer, receiving, session := s.busy, s.transfer, s.recvCmd != nil, s.session + busy, transfer, receiving, session := s.busy, s.transfer, s.receiver != nil, s.session + awaitingPeer := s.awaitingPeer + var incoming *IncomingRequest + if s.pendingIn != nil { + incoming = &IncomingRequest{ + ID: s.pendingIn.id, + PeerIP: s.pendingIn.peerIP, + Filename: s.pendingIn.filename, + Size: s.pendingIn.size, + } + } s.mu.Unlock() if !connected { session = "" // a stale session name shouldn't outlive the connection } return Status{ - Connected: connected, - MyIP: s.MyIP(), - Session: session, - Peers: peers, - Busy: busy, - Receiving: receiving, - Transfer: transfer, + Connected: connected, + MyIP: s.MyIP(), + Session: session, + Peers: peers, + Busy: busy, + Receiving: receiving, + Transfer: transfer, + AwaitingAccept: awaitingPeer != "", + AwaitingPeer: awaitingPeer, + Incoming: incoming, } } @@ -178,7 +225,7 @@ func (s *TrayService) clearTransferAfter(msg string, d time.Duration) { go func() { time.Sleep(d) s.mu.Lock() - cleared := s.transfer == msg && s.recvCmd == nil + cleared := s.transfer == msg && s.receiver == nil if cleared { s.transfer = "" } @@ -278,91 +325,206 @@ func (s *TrayService) SelectFile() (string, error) { return path, nil } -// SendFile runs `blindspot send ` and returns the CLI's final -// line. Blocks for the duration of the transfer. +// SendFile offers a file to a peer. It first asks the peer's control channel (:28126) +// for consent; on accept it streams the file over :28125. If the peer has no control +// listener (an older tray) it falls back to a direct send so mixed versions interoperate. +// Blocks until the peer decides (or a timeout) and, on accept, for the transfer. A +// failure is surfaced as the status line and returned with a nil error, matching the +// prior behavior where transfer failures were reported without an exception. func (s *TrayService) SendFile(peerIP, filePath string) (string, error) { peerIP = strings.TrimSpace(peerIP) filePath = strings.TrimSpace(filePath) if peerIP == "" || filePath == "" { return "", fmt.Errorf("a peer IP and a file are both required") } - if _, err := os.Stat(filePath); err != nil { + info, err := os.Stat(filePath) + if err != nil { return "", fmt.Errorf("file not found: %s", filePath) } s.setBusy(true) - s.setTransfer(fmt.Sprintf("Sending %s to %s…", filepath.Base(filePath), peerIP)) defer s.setBusy(false) - out, err := s.runCLI("send", peerIP, filePath) - last := lastLine(out) + // Ask for consent on the control channel first. + ctrl, err := net.DialTimeout("tcp", peerIP+requestPort, 5*time.Second) if err != nil { - if last == "" { - last = "Send failed." + if errors.Is(err, syscall.ECONNREFUSED) { + // Peer is online but has no control listener (older tray). Fall back to a + // direct send so mixed versions keep working. + return s.directSend(peerIP, filePath) + } + // Timed out / unreachable — a direct dial would just burn another timeout. + return s.reportSend("Peer is offline."), nil + } + + if err := writeRequestHeader(ctrl, filepath.Base(filePath), uint64(info.Size())); err != nil { + ctrl.Close() + return s.reportSend("Peer is unreachable."), nil + } + + s.mu.Lock() + s.pendingOutConn = ctrl + s.sendCancelled = false + s.awaitingPeer = peerIP + s.transfer = fmt.Sprintf("Waiting for %s to accept…", peerIP) + s.mu.Unlock() + s.emit() + + // Block on the 1-byte decision. The margin over requestTimeout lets the receiver's + // own timeout fire first, so we normally see an explicit decline rather than a read + // timeout. + ctrl.SetReadDeadline(time.Now().Add(requestTimeout + 5*time.Second)) + buf := make([]byte, 1) + _, rerr := io.ReadFull(ctrl, buf) + + // The await phase is over once the decision arrives (or the read fails). Clear it now, + // before any transfer, so the UI stops showing "waiting"/Cancel-send during the copy. + s.mu.Lock() + cancelled := s.sendCancelled + s.sendCancelled = false + s.pendingOutConn = nil + s.awaitingPeer = "" + s.mu.Unlock() + ctrl.Close() + + switch { + case cancelled: + // User cancelled — clear silently. + s.setTransfer("") + return "", nil + case rerr != nil: + var ne net.Error + if errors.As(rerr, &ne) && ne.Timeout() { + return s.reportSend("Peer didn't respond."), nil } - s.setTransfer(last) - s.clearTransferAfter(last, 8*time.Second) - return "", fmt.Errorf("%s", last) + return s.reportSend("Peer is unreachable."), nil + case buf[0] != 1: + return s.reportSend("Peer declined the transfer."), nil + default: + return s.directSend(peerIP, filePath) + } +} + +// directSend streams the file over the transfer port (:28125) and reports the outcome. +func (s *TrayService) directSend(peerIP, filePath string) (string, error) { + s.setTransfer(fmt.Sprintf("Sending %s to %s…", filepath.Base(filePath), peerIP)) + res, err := transfer.Send(context.Background(), peerIP, filePath, nil) + var last string + if err != nil { + last = err.Error() + } else { + last = fmt.Sprintf("Done — %s in %s (avg %s/s)", + transfer.FormatBytes(float64(res.Bytes)), res.Elapsed.Round(time.Millisecond), + transfer.FormatBytes(float64(res.Bytes)/res.Elapsed.Seconds())) } s.setTransfer(last) s.clearTransferAfter(last, 8*time.Second) return last, nil } -// StartReceive launches `blindspot receive` in the background so the tray can keep -// serving while it waits for an incoming file. Progress lines are streamed into the -// transfer status and pushed to the frontend. +// reportSend sets a transient transfer status line and returns it. +func (s *TrayService) reportSend(msg string) string { + s.setTransfer(msg) + s.clearTransferAfter(msg, 8*time.Second) + return msg +} + +// StartReceive binds an in-process receive listener so the tray can keep serving while +// it waits for an incoming file. Returning without error means the port is bound. A +// goroutine runs the one-shot Accept, streaming progress into the transfer status and +// pushing it to the frontend; when it completes the receive state clears so a later +// StartReceive rebinds cleanly. func (s *TrayService) StartReceive(here bool) error { s.mu.Lock() - if s.recvCmd != nil { + if s.receiver != nil { s.mu.Unlock() return fmt.Errorf("already waiting to receive") } + s.mu.Unlock() + return s.startReceiver(here) +} - args := []string{"receive"} +// startReceiver binds an in-process receive listener and spawns the one-shot Accept +// goroutine. It is shared by the manual StartReceive path and the consent-accept path +// in requests.go. Returning without error means the port is bound (Listen guarantees +// it), so a caller may signal readiness to a waiting sender immediately. +func (s *TrayService) startReceiver(here bool) error { + ip := s.MyIP() + if ip == "" { + return fmt.Errorf("no identity found. Run 'blindspot connect' first") + } + + var destDir string + var err error if here { - args = append(args, "--here") + destDir, err = os.Getwd() + } else { + destDir, err = transfer.DownloadsDir() } - cmd := exec.Command(cliExe(), args...) - hideConsole(cmd) - stdout, err := cmd.StdoutPipe() if err != nil { - s.mu.Unlock() return err } - cmd.Stderr = cmd.Stdout - if err := cmd.Start(); err != nil { - s.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + recv, err := transfer.Listen(ctx, ip) + if err != nil { + cancel() return err } - s.recvCmd = cmd + + s.mu.Lock() + s.receiver = recv + s.recvCancel = cancel s.transfer = "Waiting for a file…" s.mu.Unlock() s.emit() - // Stream the CLI output. It emits progress with carriage returns, so split on - // both \r and \n and surface the most recent non-empty line. go func() { - scanner := bufio.NewScanner(stdout) - scanner.Split(scanLinesOrCR) - for scanner.Scan() { - if line := strings.TrimSpace(scanner.Text()); line != "" { - s.setTransfer(line) + defer cancel() + + // Render progress snapshots into the transfer status: the first snapshot is the + // "Receiving…" header, later ones overwrite a single live line. + prog := make(chan transfer.Progress, 1) + renderDone := make(chan struct{}) + go func() { + var lr transfer.LineRenderer + first := true + for p := range prog { + if first { + first = false + lr.Seed(p) + s.setTransfer(fmt.Sprintf("Receiving %s (%d MB) from %s...", p.Name, p.Total/(1024*1024), p.PeerAddr)) + continue + } + s.setTransfer(strings.TrimSpace(lr.Line(p))) } - } - _ = scanner.Err() - cmd.Wait() + close(renderDone) + }() + + res, err := recv.Accept(ctx, destDir, prog) + close(prog) + <-renderDone + recv.Close() + s.mu.Lock() - s.recvCmd = nil - cancelled := s.recvCancelled - s.recvCancelled = false - result := s.transfer - // A user stop, or ending while still just waiting, leaves no useful result — - // clear the line so it doesn't linger. A real outcome (file saved, or an - // error mid-transfer) stays briefly, then auto-clears below. - if cancelled || strings.HasPrefix(result, "Waiting") { + s.receiver = nil + s.recvCancel = nil + var result string + switch { + case errors.Is(err, context.Canceled): + // User stopped the receive — clear rather than report. s.transfer = "" - result = "" + case err != nil && strings.HasPrefix(s.transfer, "Waiting"): + // Failed before any file arrived — nothing useful to show. + s.transfer = "" + case err != nil: + result = err.Error() + s.transfer = result + default: + result = fmt.Sprintf("Saved to %s — %s in %s (avg %s/s)", + res.Path, transfer.FormatBytes(float64(res.Bytes)), res.Elapsed.Round(time.Millisecond), + transfer.FormatBytes(float64(res.Bytes)/res.Elapsed.Seconds())) + s.transfer = result } s.mu.Unlock() s.emit() @@ -371,18 +533,16 @@ func (s *TrayService) StartReceive(here bool) error { return nil } -// CancelReceive kills a pending `receive` process, if one is running. +// CancelReceive cancels a pending receive, if one is running. Cancelling the context +// unblocks the in-process Accept, whose goroutine then clears the receive state. func (s *TrayService) CancelReceive() error { s.mu.Lock() - cmd := s.recvCmd - if cmd != nil { - s.recvCancelled = true - } + cancel := s.recvCancel s.mu.Unlock() - if cmd == nil || cmd.Process == nil { - return nil + if cancel != nil { + cancel() } - return cmd.Process.Kill() + return nil } // Version returns the blindspot version string. @@ -412,20 +572,3 @@ func lastLine(s string) string { } return "" } - -// scanLinesOrCR is a bufio.SplitFunc that breaks tokens on either newline or -// carriage return, so progress-bar updates (which use \r) surface as they arrive. -func scanLinesOrCR(data []byte, atEOF bool) (advance int, token []byte, err error) { - if atEOF && len(data) == 0 { - return 0, nil, nil - } - for i, b := range data { - if b == '\n' || b == '\r' { - return i + 1, data[:i], nil - } - } - if atEOF { - return len(data), data, nil - } - return 0, nil, nil -} diff --git a/internal/transfer/errors.go b/internal/transfer/errors.go new file mode 100644 index 0000000..8185267 --- /dev/null +++ b/internal/transfer/errors.go @@ -0,0 +1,81 @@ +package transfer + +import "fmt" + +// Op identifies the phase of a transfer that failed. Callers may switch on it, but +// the Error's own message already reproduces the user-facing wording, so the common +// path is simply to print the error. +type Op string + +const ( + OpOpenFile Op = "open_file" + OpStat Op = "stat" + OpDial Op = "dial" + OpSendNameLen Op = "send_name_len" + OpSendName Op = "send_name" + OpSendSize Op = "send_size" + OpSendBody Op = "send_body" + OpListen Op = "listen" + OpAccept Op = "accept" + OpReadNameLen Op = "read_name_len" + OpReadName Op = "read_name" + OpReadSize Op = "read_size" + OpCreateFile Op = "create_file" + OpRecvBody Op = "recv_body" + OpHomeDir Op = "home_dir" + OpMkdirDownloads Op = "mkdir_downloads" +) + +// Error is a typed transfer error carrying the phase that failed plus the context +// needed to render the exact user-facing message. Its Error() string is identical to +// what the CLI historically printed, so both the CLI and the tray can surface it +// directly without re-deriving the wording. It unwraps to the underlying cause. +type Error struct { + Op Op + Err error // underlying cause (nil for OpDial, which is intentionally friendly) + Peer string // OpDial: the peer IP + Addr string // OpListen: the address we tried to bind + Got int64 // OpRecvBody: bytes received before the failure + Total int64 // OpRecvBody: expected total +} + +func (e *Error) Error() string { + switch e.Op { + case OpOpenFile: + return fmt.Sprintf("Error opening file: %v", e.Err) + case OpStat: + return fmt.Sprintf("Error reading file info: %v", e.Err) + case OpDial: + return fmt.Sprintf("Peer %s is not receiving. Ask them to run 'blindspot receive'.", e.Peer) + case OpSendNameLen: + return fmt.Sprintf("Error sending filename length: %v", e.Err) + case OpSendName: + return fmt.Sprintf("Error sending filename: %v", e.Err) + case OpSendSize: + return fmt.Sprintf("Error sending file size: %v", e.Err) + case OpSendBody: + return fmt.Sprintf("Error sending file: %v", e.Err) + case OpListen: + return fmt.Sprintf("Could not listen on %s: %v", e.Addr, e.Err) + case OpAccept: + return fmt.Sprintf("Error accepting connection: %v", e.Err) + case OpReadNameLen: + return fmt.Sprintf("Error reading filename length: %v", e.Err) + case OpReadName: + return fmt.Sprintf("Error reading filename: %v", e.Err) + case OpReadSize: + return fmt.Sprintf("Error reading file size: %v", e.Err) + case OpCreateFile: + return fmt.Sprintf("Error creating file: %v", e.Err) + case OpRecvBody: + return fmt.Sprintf("Error receiving file (got %d/%d bytes): %v", e.Got, e.Total, e.Err) + case OpHomeDir: + return fmt.Sprintf("Error finding home directory: %v", e.Err) + case OpMkdirDownloads: + return fmt.Sprintf("Error creating Downloads directory: %v", e.Err) + default: + return fmt.Sprintf("transfer error (%s): %v", e.Op, e.Err) + } +} + +func (e *Error) Unwrap() error { return e.Err } diff --git a/internal/transfer/progress.go b/internal/transfer/progress.go new file mode 100644 index 0000000..006786a --- /dev/null +++ b/internal/transfer/progress.go @@ -0,0 +1,85 @@ +package transfer + +import ( + "fmt" + "time" +) + +// Progress is a raw byte-count snapshot pushed during a transfer. The set-once fields +// (Name, PeerAddr, Total) repeat on every snapshot. The first snapshot sent for a +// transfer has Transferred == 0 and arrives before any bytes flow, so a caller can +// render its "Sending…/Receiving…" header from it. The caller owns the channel and +// closes it once Send/Accept has returned; Send and Accept never close it. +type Progress struct { + Name string // file base name + PeerAddr string // remote address (receive); empty for send + Transferred int64 + Total int64 +} + +// Result summarizes a finished transfer and drives the persistent summary line. +type Result struct { + Name string + Path string // receive: destination path; send: source path + Bytes int64 + Elapsed time.Duration +} + +// FormatBytes renders a byte count in B / KB / MB, matching the historical CLI output. +func FormatBytes(b float64) string { + switch { + case b >= 1024*1024: + return fmt.Sprintf("%.2f MB", b/1024/1024) + case b >= 1024: + return fmt.Sprintf("%.2f KB", b/1024) + default: + return fmt.Sprintf("%.0f B", b) + } +} + +// LineRenderer turns consecutive Progress snapshots into the transient one-line +// " 50.0% 12.00 MB/s ETA 3s " progress display (without the leading carriage +// return, which the caller adds). It tracks the last sample to compute the live speed, +// exactly as the old startProgress ticker did. Seed it with the first snapshot so the +// first rendered line measures from the transfer's start. +type LineRenderer struct { + lastBytes int64 + lastTick time.Time + seeded bool +} + +// Seed records the starting point (typically the Transferred==0 header snapshot) so the +// next Line call measures speed from here. +func (lr *LineRenderer) Seed(p Progress) { + lr.lastBytes = p.Transferred + lr.lastTick = time.Now() + lr.seeded = true +} + +// Line renders the progress display for snapshot p and advances the internal cursor. +func (lr *LineRenderer) Line(p Progress) string { + now := time.Now() + if !lr.seeded { + lr.lastBytes = 0 + lr.lastTick = now + lr.seeded = true + } + delta := p.Transferred - lr.lastBytes + elapsed := now.Sub(lr.lastTick) + lr.lastBytes = p.Transferred + lr.lastTick = now + + var speed float64 + if elapsed > 0 { + speed = float64(delta) / elapsed.Seconds() + } + pct := float64(p.Transferred) / float64(p.Total) * 100 + var eta string + if speed > 0 { + secs := float64(p.Total-p.Transferred) / speed + eta = fmt.Sprintf("ETA %s", time.Duration(secs*float64(time.Second)).Round(time.Second)) + } else { + eta = "ETA --" + } + return fmt.Sprintf(" %.1f%% %s %s ", pct, FormatBytes(speed)+"/s", eta) +} diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go new file mode 100644 index 0000000..093764b --- /dev/null +++ b/internal/transfer/transfer.go @@ -0,0 +1,269 @@ +// Package transfer is the headless core of Blindspot's file-transfer path on the +// :28125 port. It owns the wire protocol and the send/receive logic, exposing them as +// plain in-process calls: no flag parsing, no printing, no os.Exit. Cancellation flows +// through context.Context and progress through a channel of Progress snapshots, so both +// the CLI and the tray can drive it as peers. +// +// Wire protocol (unchanged, big-endian): uint16 name length, name bytes, uint64 file +// size, then the raw file body. +package transfer + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync/atomic" + "time" +) + +// Port is the TCP port used for file transfers. +const Port = 28125 + +// dialTimeout matches the historical CLI dial timeout. +const dialTimeout = 5 * time.Second + +// sampleInterval is how often live progress snapshots are emitted during a transfer. +const sampleInterval = 500 * time.Millisecond + +// progressWriter is an io.Writer that counts the bytes passing through it, so a +// background sampler can report progress without disturbing the copy. +type progressWriter struct { + w io.Writer + bytes atomic.Int64 +} + +func (pw *progressWriter) Write(p []byte) (n int, err error) { + n, err = pw.w.Write(p) + pw.bytes.Add(int64(n)) + return +} + +// startSampler emits a Progress snapshot every sampleInterval, carrying the current +// byte count over the given base (Name/Total/PeerAddr). It runs in its own goroutine +// and blocking sends never gate the copy. The returned stop func closes the sampler +// and waits for it to finish, so the caller may safely close prog afterwards. If prog +// is nil the sampler is a no-op. +func startSampler(prog chan<- Progress, pw *progressWriter, base Progress) func() { + if prog == nil { + return func() {} + } + stopCh := make(chan struct{}) + doneCh := make(chan struct{}) + go func() { + defer close(doneCh) + ticker := time.NewTicker(sampleInterval) + defer ticker.Stop() + for { + select { + case <-stopCh: + return + case <-ticker.C: + p := base + p.Transferred = pw.bytes.Load() + select { + case prog <- p: + case <-stopCh: + return + } + } + } + }() + return func() { + close(stopCh) + <-doneCh + } +} + +// closeOnCancel closes c when ctx is done, unblocking a pending Accept/Read/Copy. The +// returned stop func ends the watch (call it before c is otherwise closed). +func closeOnCancel(ctx context.Context, c io.Closer) func() { + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + c.Close() + case <-done: + } + }() + return func() { close(done) } +} + +// Send dials peer:Port, writes the header, and streams the file at path. peer is the IP +// only; the package appends Port. Progress snapshots are pushed on prog (which may be +// nil to skip progress); the caller owns and closes prog after Send returns. On failure +// Send returns a *transfer.Error, or a context error if ctx was cancelled. +func Send(ctx context.Context, peer, path string, prog chan<- Progress) (*Result, error) { + f, err := os.Open(path) + if err != nil { + return nil, &Error{Op: OpOpenFile, Err: err} + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return nil, &Error{Op: OpStat, Err: err} + } + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", peer, Port), dialTimeout) + if err != nil { + return nil, &Error{Op: OpDial, Err: err, Peer: peer} + } + defer conn.Close() + + stopWatch := closeOnCancel(ctx, conn) + defer stopWatch() + + name := []byte(filepath.Base(path)) + if err := binary.Write(conn, binary.BigEndian, uint16(len(name))); err != nil { + return nil, &Error{Op: OpSendNameLen, Err: err} + } + if _, err := conn.Write(name); err != nil { + return nil, &Error{Op: OpSendName, Err: err} + } + if err := binary.Write(conn, binary.BigEndian, uint64(info.Size())); err != nil { + return nil, &Error{Op: OpSendSize, Err: err} + } + + base := Progress{Name: filepath.Base(path), Total: info.Size()} + if prog != nil { + prog <- base // header snapshot (Transferred == 0), before any body bytes + } + + pw := &progressWriter{w: conn} + stopSampler := startSampler(prog, pw, base) + start := time.Now() + n, err := io.Copy(pw, f) + stopSampler() + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &Error{Op: OpSendBody, Err: err} + } + + return &Result{ + Name: base.Name, + Path: path, + Bytes: n, + Elapsed: time.Since(start), + }, nil +} + +// Receiver is a bound, one-shot transfer listener. Listen returning without error means +// the port is already bound; call Accept to receive a single file, then Close. +type Receiver struct { + ln net.Listener + addr string +} + +// Listen binds ip:Port. A nil error guarantees the port is bound and the returned +// Receiver holds the ready listener. ip is the caller-derived virtual IP. +func Listen(ctx context.Context, ip string) (*Receiver, error) { + addr := fmt.Sprintf("%s:%d", ip, Port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, &Error{Op: OpListen, Err: err, Addr: addr} + } + return &Receiver{ln: ln, addr: addr}, nil +} + +// Addr returns the bound address, e.g. "10.x.y.z:28125". +func (r *Receiver) Addr() string { return r.addr } + +// Close closes the underlying listener. +func (r *Receiver) Close() error { return r.ln.Close() } + +// Accept blocks for one connection, reads the header, and saves the file into destDir, +// streaming the body. It is one-shot. Progress snapshots are pushed on prog (which may +// be nil); the caller owns and closes prog after Accept returns. On failure it returns +// a *transfer.Error, or a context error if ctx was cancelled. +func (r *Receiver) Accept(ctx context.Context, destDir string, prog chan<- Progress) (*Result, error) { + stopLnWatch := closeOnCancel(ctx, r.ln) + conn, err := r.ln.Accept() + stopLnWatch() + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &Error{Op: OpAccept, Err: err} + } + defer conn.Close() + + stopConnWatch := closeOnCancel(ctx, conn) + defer stopConnWatch() + + var nameLen uint16 + if err := binary.Read(conn, binary.BigEndian, &nameLen); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &Error{Op: OpReadNameLen, Err: err} + } + nameBuf := make([]byte, nameLen) + if _, err := io.ReadFull(conn, nameBuf); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &Error{Op: OpReadName, Err: err} + } + + var fileSize uint64 + if err := binary.Read(conn, binary.BigEndian, &fileSize); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &Error{Op: OpReadSize, Err: err} + } + + filename := string(nameBuf) + destPath := filepath.Join(destDir, filename) + + base := Progress{Name: filename, PeerAddr: conn.RemoteAddr().String(), Total: int64(fileSize)} + if prog != nil { + prog <- base // header snapshot (Transferred == 0), before any body bytes + } + + f, err := os.Create(destPath) + if err != nil { + return nil, &Error{Op: OpCreateFile, Err: err} + } + defer f.Close() + + pw := &progressWriter{w: f} + stopSampler := startSampler(prog, pw, base) + start := time.Now() + n, err := io.CopyN(pw, conn, int64(fileSize)) + stopSampler() + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &Error{Op: OpRecvBody, Err: err, Got: n, Total: int64(fileSize)} + } + + return &Result{ + Name: filename, + Path: destPath, + Bytes: n, + Elapsed: time.Since(start), + }, nil +} + +// DownloadsDir returns the ~/Downloads directory, creating it if needed. It is shared by +// the CLI and the tray for the default (non---here) receive destination. On failure it +// returns a *transfer.Error whose message matches the historical CLI wording. +func DownloadsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", &Error{Op: OpHomeDir, Err: err} + } + dir := filepath.Join(home, "Downloads") + if err := os.MkdirAll(dir, 0755); err != nil { + return "", &Error{Op: OpMkdirDownloads, Err: err} + } + return dir, nil +} diff --git a/internal/transfer/transfer_test.go b/internal/transfer/transfer_test.go new file mode 100644 index 0000000..10d4b34 --- /dev/null +++ b/internal/transfer/transfer_test.go @@ -0,0 +1,174 @@ +package transfer + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +// collect drains prog into a counter until it is closed, signalling done when finished. +// It models a caller's render goroutine under the "caller owns and closes prog" contract. +func collect(prog <-chan Progress, count *int, done chan<- struct{}) { + for range prog { + *count++ + } + close(done) +} + +func TestRoundTrip(t *testing.T) { + const ip = "127.0.0.1" + content := bytes.Repeat([]byte("blindspot-"), 5000) // 50 KB + src := filepath.Join(t.TempDir(), "payload.bin") + if err := os.WriteFile(src, content, 0644); err != nil { + t.Fatalf("write source: %v", err) + } + destDir := t.TempDir() + + recv, err := Listen(context.Background(), ip) + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer recv.Close() + + type recvOutcome struct { + res *Result + err error + progSeen int + } + recvCh := make(chan recvOutcome, 1) + go func() { + prog := make(chan Progress, 64) + seen := 0 + pdone := make(chan struct{}) + go collect(prog, &seen, pdone) + res, err := recv.Accept(context.Background(), destDir, prog) + close(prog) + <-pdone + recvCh <- recvOutcome{res, err, seen} + }() + + // Send from the "main" goroutine. + sendProg := make(chan Progress, 64) + sendSeen := 0 + sdone := make(chan struct{}) + go collect(sendProg, &sendSeen, sdone) + sendRes, sendErr := Send(context.Background(), ip, src, sendProg) + close(sendProg) + <-sdone + if sendErr != nil { + t.Fatalf("Send: %v", sendErr) + } + + out := <-recvCh + if out.err != nil { + t.Fatalf("Accept: %v", out.err) + } + + // Bytes match on both sides. + if sendRes.Bytes != int64(len(content)) { + t.Errorf("send bytes = %d, want %d", sendRes.Bytes, len(content)) + } + if out.res.Bytes != int64(len(content)) { + t.Errorf("recv bytes = %d, want %d", out.res.Bytes, len(content)) + } + + // The saved file matches the source, at the expected path. + wantPath := filepath.Join(destDir, "payload.bin") + if out.res.Path != wantPath { + t.Errorf("dest path = %q, want %q", out.res.Path, wantPath) + } + got, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("read dest: %v", err) + } + if !bytes.Equal(got, content) { + t.Errorf("received content differs from source (got %d bytes)", len(got)) + } + + // At least the header snapshot must reach each side. + if sendSeen < 1 { + t.Errorf("no send progress observed") + } + if out.progSeen < 1 { + t.Errorf("no receive progress observed") + } +} + +// TestListenRebind proves the one-shot receive lifecycle the tray relies on: after a +// receiver accepts a file and is closed, the port is free and a fresh Listen on the same +// address binds cleanly (no "address already in use"). +func TestListenRebind(t *testing.T) { + const ip = "127.0.0.1" + src := filepath.Join(t.TempDir(), "f.bin") + if err := os.WriteFile(src, []byte("round two payload"), 0644); err != nil { + t.Fatalf("write source: %v", err) + } + + for i := range 2 { + recv, err := Listen(context.Background(), ip) + if err != nil { + t.Fatalf("Listen (iteration %d): %v", i, err) + } + done := make(chan error, 1) + go func() { + _, err := recv.Accept(context.Background(), t.TempDir(), nil) + done <- err + }() + if _, err := Send(context.Background(), ip, src, nil); err != nil { + t.Fatalf("Send (iteration %d): %v", i, err) + } + if err := <-done; err != nil { + t.Fatalf("Accept (iteration %d): %v", i, err) + } + recv.Close() + } +} + +func TestAcceptCancel(t *testing.T) { + const ip = "127.0.0.1" + recv, err := Listen(context.Background(), ip) + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer recv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, err := recv.Accept(ctx, t.TempDir(), nil) + errCh <- err + }() + + // No peer ever connects; cancel should unblock Accept. + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case err := <-errCh: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Accept error = %v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Accept did not return after cancellation") + } +} + +func TestSendPeerUnreachable(t *testing.T) { + src := filepath.Join(t.TempDir(), "f.bin") + if err := os.WriteFile(src, []byte("hi"), 0644); err != nil { + t.Fatalf("write source: %v", err) + } + // 127.0.0.1 with nothing listening on Port -> dial fails. + _, err := Send(context.Background(), "127.0.0.1", src, nil) + var te *Error + if !errors.As(err, &te) || te.Op != OpDial { + t.Fatalf("Send error = %v, want *Error{Op: OpDial}", err) + } + if te.Peer != "127.0.0.1" { + t.Errorf("dial error peer = %q, want 127.0.0.1", te.Peer) + } +}