Documentation
¶
Overview ¶
Package commands cmd/apps/skychat/commands/auth.go c4-app-chat
HTTP basic-auth gate for skychat. Off by default; on when a password file is configured and non-empty.
File format mirrors the hypervisor's user-store hashing scheme (salt + SHA256, see pkg/visor/usermanager/user.go) so the same password-set flow works the same way for both surfaces. The file holds a single line of "<hex-salt>:<hex-hash>". The password-file path is managed via the hypervisor UI; the visor writes / removes it on SetSkychatPassword / ClearSkychatPassword.
The hypervisor reverse-proxy at /api/visors/<pk>/skychat/* runs in-process on the visor and bypasses this gate via a startup- random "internal token" in the X-Skychat-Internal-Token header, so authenticated hvui sessions don't have to re-authenticate to skychat. The standalone :8001 surface stays password-gated.
Package commands cmd/apps/skychat/commands/contacts.go c4-app-chat
The address book's HTTP surface, and the one function every display path should use to turn a public key into something a human recognizes.
GET /contacts the whole book: {"<pk>": "<name>", …}
POST /contacts {pk, name} — set, or forget with an empty name
POST /contacts/import {names:{…}} — fill gaps only (UI migration)
Why it is served rather than kept in the UI: a nickname used to live in the chat page's localStorage, where the two surfaces that need it most could not see it — a notification title, composed here in Go, and a phone's native call screen, which is Kotlin. Both showed hex for a person the user had already named. See pkg/skychat/contacts for the rest of the reasoning.
Package commands cmd/apps/skychat/commands/cxo_tcp.go c4-app-chat node's NATIVE TCP transport (no dmsg). Opt-in via --cxo; works in --standalone. Outbound /message is published to our own CXO feed; each --cxo-peer feed is subscribed and surfaced on the SSE stream, reusing the same /message + /sse surfaces as the tcp-direct bridge.
A 1:1 P2P pair is just a two-member group on this publish/subscribe mechanism, so groups (docs/skychat_cxo_tcp_standalone.md, Phase 2) extend this by adding more --cxo-peer subscriptions to a shared feed.
Package commands cmd/apps/skychat/commands/dmstatus.go c4-app-chat
DM message-status lifecycle: failed / pending / sent / received / read.
pending/sent/failed are decided at send time and owned by the browser (the optimistic bubble flips on the /message response). received/read arrive asynchronously as peer receipts and are surfaced here as "dm-status" control events on the legacy /sse stream, so the sender's UI can advance a bubble's tick long after the send returned:
- received: the recipient's app auto-sends a chat-ack when it processes our chat-msg (ack=true). handleConn turns that ack into a received status.
- read: the recipient's UI, on displaying the message, POSTs /read-receipt, which sends a chat-read envelope back. handleConn turns that into a read status.
A status event is {channel:"dm-status", id, status, peer}: id is the sender- side message id (the chat-msg envelope id, echoed by the receipt), peer is the other party's PK so the browser routes the update to the right thread. Line-based /sse consumers (cli skychat listen) ignore channel-tagged control events, exactly as they do for group / pair-invite events.
Package commands cmd/apps/skychat/commands/filebackfill.go c4-app-chat
File backfill: let a member who is missing a file's bytes ask the holder to re-send it. Files ride an out-of-band xfer stream at send time, so they are NOT replicated the way messages are (message history reaches a new group joiner via CXO feed replication; file bytes don't). This closes that gap with a small request/re-send protocol:
- Requester POSTs /request-file {pk, file_id, file_name}. The app sends a file-request control envelope to the peer (the file's original sender) over the chat conn and records the id as expected.
- Holder's handleConn recognizes the envelope, finds the file by id (the sender's served copy is named <id><ext>, so it's directly lookup-able; a received copy is checked too), and re-sends it over xfer preserving the ORIGINAL id + name.
- Requester's auto-accept admits the re-sent file because it asked for it, even from a peer it hasn't otherwise established a chat with.
The protocol is peer-to-peer by PK, so it serves DM file recovery directly and routes group-file backfill to the message's sender PK.
Package commands cmd/apps/skychat/commands/filecrypt.go c4-app-chat
The on-disk and on-the-wire container for a group attachment, sealed under a per-file key derived from the group key (see the group package's filekey.go for the derivation and why the group key itself never leaves the visor).
Why a container and not "encrypt the bytes" ¶
A group attachment has to survive three things a plain AES blob does not:
- It is SERVED to the local browser at /files/<name>, and the UI plays video and audio out of it. That means HTTP Range requests, which means the decrypted view has to be seekable — an all-at-once decrypt would either break seeking or pull a 35 MB clip into memory on every scrub.
- It is RE-SENT verbatim by the backfill path. The holder ships the bytes it stored without opening them, so the container must be self-describing: whoever ends up with it has to know which group's key opens it without being told out of band.
- It must fail loudly on tampering rather than yield plausible garbage into an image decoder.
So: fixed-size plaintext chunks, each its own AES-256-GCM message.
"SGF1" | u8 len | group id | u8 len | file id | u16 len | name | u64 plaintext size | chunk* chunk = 12-byte nonce | GCM(chunk plaintext) + 16-byte tag
The group id and file id in the header are what "self-describing" means concretely: the key is derived from exactly that pair, so a file found on disk — or re-sent to a member who has never seen its feed message — carries everything needed to ask for the right key. Deriving them from the FILENAME instead was the obvious shortcut and does not work: a received copy keeps the sender's original name, which contains no id at all.
Every chunk is sealed with the whole header as additional data, plus its index and a final-chunk flag. That binds three things at once: the declared size and group cannot be edited (the tags stop verifying), chunks cannot be reordered or swapped between files, and the file cannot be truncated silently — the last chunk says it is the last one, so a cut tail surfaces as a missing final chunk rather than as a short read.
Plaintext size in the header is what makes the seek arithmetic direct: plaintext offset N lives in chunk N/chunkPlainSize at a fixed ciphertext offset, so a Range request decrypts one chunk instead of the file.
What it does not hide ¶
The attachment's NAME and SIZE. Both ride the xfer offer in the clear so the receiver can name the file on disk, and both are in the group message that references the file anyway — sealed there, but a member who asks for the bytes has already read it. The size is also inherent: the container is a constant factor larger than the plaintext. Hiding either means padding and an anonymous-id save path, which is a bigger change than this and buys little while the reference itself is already sealed.
Package commands cmd/apps/skychat/commands/filecrypt_keys.go c4-app-chat
Where the chat app gets the key for a group attachment, and the two wrappers every caller actually uses: seal-this-file-for-that-group, and open-whatever-this-file-turns-out-to-be.
Two sources, because the app runs in two shapes:
- Visor-managed groups (the normal one): the group record lives in the visor, so the key comes back over the same RPC the rest of the group surface uses. The visor derives a key scoped to ONE file id and returns that — never the group key. See pkg/visor.Visor.GroupFileKey.
- Standalone --cxo-group: the app owns the group session in-process, so it derives the same key from the session directly. Same derivation, no RPC.
Keys are not cached. A cache would have to be invalidated on every rotation, eviction and re-join to avoid sealing under a key the group has retired — and the derivation is one HKDF over a key the visor already has in memory, so there is nothing to save.
Package commands cmd/apps/skychat/commands/filexfer.go c4-app-chat
File sharing for skychat (Telegram-style: a file is a message in the conversation). The bytes ride pkg/skychat/xfer over a skywire stream (skynet or dmsg — same port on both, skyenv.SkychatFilePort), OUT OF BAND from the chat-message conn so a large transfer never blocks chat. The offer + result surface as a chatEvent (file_* fields) into the same SSE/history stream as text messages, so a UI renders it inline like any other message.
Auto-accept policy (operator decision): once you've ESTABLISHED a chat with a peer (there's a live conn) or you share a GROUP, inbound files are accepted automatically — no per-file prompt. Files from a peer you've never talked to are declined. Everything is encrypted by the skywire transport; nothing touches the clearnet.
Package commands cmd/apps/skychat/commands/forward.go
Forwarded messages. Same shape as reply.go — a small JSON envelope riding the ordinary message body — so this needs no new send endpoint and no wire version bump, and a reader on an older build sees the forwarded text rather than a broken card.
The privacy rule is in the type, not in the callers ¶
A forward may name the CHANNEL it came from and nothing else. There is no field here for the original author's name, public key, or timestamp, and that absence is the whole design: a rule enforced by "remember not to fill this in" is a rule that survives until the first person forgets, whereas a field that does not exist cannot be populated by any future call site.
Why channels are the exception: a channel is a broadcast to an audience its author cannot enumerate, published under the channel's identity rather than a person's. Naming it when a post travels further is repeating what was already public. A DM has exactly one intended reader and a group has a roster; in both, the author chose an audience, and forwarding with attribution would hand a stranger both the content AND the fact that a specific person said it — which is the leak, not the content.
So: From carries a channel's display NAME, never a key. Even for channels the identity is deliberately the weaker one — a name is enough to say where something came from, and a key would let a forward be used to enumerate who is in an audience.
What it is worth ¶
Nothing here is signed, so "Forwarded from X" is a claim by the forwarder, exactly like a pasted quotation. It is rendered as a quiet chip rather than a badge for that reason — the same stance PriceHint takes in the group package. The value is in the common case (a reader knows where a post came from) and the cost of a lie is what it would be anyway if the forwarder had simply typed the words.
Package commands cmd/apps/skychat/commands/group.go c4-app-chat
Browser-facing group chat: HTTP endpoints that proxy the visor's GroupX RPC (the same net/rpc surface the pair endpoints + /status use via pairRPCCall) plus an SSE bridge that drains visor.GroupPoll onto the chat stream. This is the group analog of pairing.go — the browser UI can only reach the app's HTTP surface, so every group operation is relayed to the visor here.
Gated behind --pair-enable: groups need the visor RPC connection, which is only dialed when pairing is on. When it's off these are no-ops and the UI falls back to a group-less view (the /group endpoints aren't registered).
Package commands cmd/apps/skychat/commands/link.go c4-app-chat
The peer link — "is there a way to this person right now, and if not, make one" — as an endpoint of its own.
A DM send has always dialed on demand: the first message to a peer carries the handshake inside its own request. That is fine for a CLI, where a command that takes half a minute is a command that takes half a minute. It is not fine for a person holding a phone. Setting up a skynet route means finding a path and building it hop by hop, and while that runs the sender sees a message sitting there with nothing to say for itself — so they send another, and another, and conclude the app is broken. Meanwhile every one of those sends starts a dial of its own.
So the UI asks for the link first, up front, and is told which of three things is true: it is up, it is being built, or it could not be built and here is why. What it does with that — hold the composer's messages in a queue and say what it is waiting for — is the browser's business; this file is only the honest answer.
Nothing else changes. The link is the same cached conn `Send` would have made, made a little earlier and under its own name, so a message that goes out after one lands exactly as it did before.
Package commands cmd/apps/skychat/commands/osnotify.go c4-app-chat
Host-OS desktop notifications for inbound messages — the background-process complement to the browser's Web Notifications (static/index.html). The two never double up: the browser owns notifications whenever a UI that CAN show them is attached (it alone knows which thread is focused + per-thread mutes); the Go process fills the gap when NO capable UI is present — the UI is closed, or it's open but the browser blocked/disabled notifications.
"A capable UI is present" combines two signals:
- SSE client count (hub.clientCount) — instant: a closed tab drops to 0, so the Go side takes over immediately, no waiting on a lease.
- A capability lease refreshed by POST /notify-capable {capable:true}. The browser heartbeats this only while it can actually notify; when it can't (permission denied / notifications turned off) it stops (or posts capable:false) and the lease goes stale, so the Go side resumes.
Once past that gate skychat is done deciding: it publishes to the VISOR's notification hub (pkg/visor/notifyhub.go) and the hub picks the sink that can actually reach the user — a subscribed host app (the Android service), the host OS notification center, or nothing at all on a headless visor. The split is deliberate: the app knows *whether* an event deserves attention (focus, mutes), the visor knows *where* the user can be reached.
Note the gate no longer consults osnotify.Available(). It cannot: on Android there is no desktop session, yet the host-app sink is exactly the one that must fire. Dropping it is behavior-neutral on the desktop because osnotify.Notify re-checks availability itself.
--standalone is the one exception: with no visor there is no hub to publish to, so that mode keeps posting to the host OS directly.
Package commands cmd/apps/skychat/commands/pairing.go c4-app-chat
Pair-feed integration: HTTP endpoints, structured-message handshake over the legacy direct path, and the inbound-poll bridge into the existing SSE pipeline.
All pairing functionality is gated behind --pair-enable so the CI e2e tests that exercise the legacy plain-text DMs are unaffected. When the flag is on, skychat opens an RPC connection to the local visor (localhost:3435 by default) and exposes the visor's pair methods over its HTTP API.
Handshake flow (Alice initiates with Bob):
- Alice's UI: POST /pair {peer_pk: bob}
- Alice's skychat: visor.PairAdd(bob) (status pending); send {type:"pair-invite"} to Bob via legacy direct dial.
- Bob's skychat handleConn: stores invite in pending set, pushes a channel="pair-invite" SSE event so the UI shows accept/decline buttons. NO automatic PairAdd on Bob's side.
- Bob accepts via UI → POST /pair/invites/<alice>/accept: visor.PairAdd(alice) + visor.PairMarkActive(alice); send {type:"pair-ack"} to alice. (Decline path sends {type:"pair-decline"} and drops the pending entry.)
- Alice's skychat handleConn: pair-ack → visor.PairMarkActive(bob); pair-decline → visor.PairRemove(bob). Both sides now in sync.
Legacy plain-text DMs continue to work alongside the structured envelope: handleConn first tries to JSON-decode each frame; on parse error or unrecognized type it falls back to the plain-text path.
Package commands cmd/apps/skychat/commands/presence.go c4-app-chat
Peer presence (the online/offline dot next to a contact).
A peer is "online" when its VISOR answers over dmsg right now. The probe is the visor's own dmsg-ping surface (DialDmsgPing → DmsgPingOnce → StopDmsgPing, relayed through the pairRPCCall seam like the group and voice endpoints), so nothing new is needed on the peer's side and no route is set up — a dmsg stream to the peer's visor either establishes or it doesn't.
Probing happens in a BACKGROUND loop, never in the request path: dialing an offline peer can take seconds, and a browser poll must not wait for it. The UI posts the contact list it cares about, gets the latest snapshot back immediately, and asks again a minute later. Endpoints 503 without --pair-enable (no visor RPC to relay through), so the UI just hides the dots.
Package commands cmd/apps/skychat/commands/profile.go c4-app-chat
The browser's door to skychat profiles: read and write this visor's own published name and avatar, and look up a peer's.
Same shape as group.go's handlers and for the same reasons — the visor owns the store and the wire, this file owns nothing but the HTTP verb mapping and the error codes a UI can act on. Gated on --pair-enable because everything here goes through the visor RPC seam; without it the routes are not registered at all, which the UI reads as 404 and treats as "this build has no profiles" rather than "the request failed".
Package commands cmd/apps/skychat/commands/reply.go
Quoted replies for DM and group messages. A reply rides the ordinary message body as a small JSON envelope — the same reference-in-the-text pattern the group-file feature uses ({"skychat_file":...}) — so it needs no new send endpoint and no wire-version bump: the sender encodes the envelope, and every read boundary (DM /sse, DM /history, group SSE poller, group /history) detects it, surfaces the real text as the message body, and adds additive reply_to_* fields the browser renders as a quote block.
The reference is denormalized: the parent's sender + timestamp locate it for scroll-to, and a short preview is embedded so the quote renders even for a reader who doesn't hold the parent (a fresh group joiner, or after backfill). Raw non-enriching consumers (e.g. `cli skychat listen` on an old build) see the reply's plain text — the same graceful degradation as file messages.
Package commands cmd/apps/skychat/commands/sendack.go c4-app-chat
The chat-msg / chat-ack envelope, the per-id ack-waiter map, and the inbound envelope decode all moved into the shared controller (pkg/skychat/dm) when the DM core was extracted — the native app now calls dm.Controller.Send with a WaitAck duration instead of registering waiters itself, and the controller routes inbound chat-ack envelopes to those waiters internally.
What remains here is the HTTP-layer clamp for the /message handler's wait_ms parameter, which bounds how long a --wait request may block the controller's ack channel.
Package commands cmd/apps/skychat/commands/skychat.go c4-app-chat
Package commands cmd/apps/skychat/commands/tcp_direct.go c4-app-chat chat-app's noise-TCP entry points, the reliability-floor transport described in #2706. Same noise-XK primitive dmsgpty uses, applied to chat frames so agents can reach each other when dmsg-disc / skynet / visor itself is misbehaving.
Two halves:
--tcp-listen :PORT — accept noise-XK on TCP, wrap the resulting conn so chat-app's existing handleConn loop (which expects appnet.Addr on RemoteAddr) can consume it unchanged.
--tcp-peer tcp://<pk>@host:port (repeatable) — maintain a persistent outbound dial per listed peer. Reconnect with exponential backoff. Lets agents behind NAT reach public-IP peers and HAVE A BIDIRECTIONAL channel: once the handshake completes, the listener side can write back over the same socket. Operator only port-forwards on the public side; NAT side runs --tcp-peer only.
Identity comes from the chat-app's --sk / -c / DMSGCURL_SK resolution chain (skychat.go owns those flags). Whitelist (the chat-app's --tcp-whitelist set) gates incoming peers: a non-empty list restricts to those PKs; an EMPTY list is open (accepts any authenticated peer), matching the skywire convention (skynet Server.useWL, CXO treestore nil-allowlist). Peers are always authenticated by the noise XK handshake — open means unrestricted, not anonymous.
Package commands cmd/apps/skychat/commands/transfer.go c4-app-chat
Moving a chat between devices, by hand.
GET /export the archive: address book + message history GET /export/<name> the same, with a filename in the path (see below) POST /import merge an archive into this visor
Nothing about skychat syncs. Two visors are two identities with two stores, and a message that reached one was never addressed to the other — so a desktop's history simply does not exist on a phone. Rather than pretend otherwise, this is the manual path: one JSON file out of the old device and into the new one.
What travels is what is DATA: the messages this visor has kept and the names the operator gave to keys. What does not travel is everything that is an IDENTITY or a key — the visor's own keypair, group membership and its key material, pairing ratchets, undelivered transfers. Importing a group's messages puts the conversation on the new device to read; it does not make that device a member, which only rejoining does.
Package commands cmd/apps/skychat/commands/voice.go c4-app-chat
Browser-facing HTTP proxy for skychat 1:1 VOICE CALLS. Like the group endpoints, these relay to the visor's net/rpc surface (visor.API Voice*, via the existing pairRPCCall seam) — the SAME surface the hypervisor UI and `skywire-cli skychat voice` drive. The call manager, the media session, and the microphone/speaker all live in the VISOR (pkg/skychat/call, brought up by init_voice.go with host audio + explicit-answer); this app is only a control surface + level meter, so there is no audio code here.
Model: audio is HOST audio on the machine the visor runs on. The realistic deployment is a desktop where the visor and this browser UI share the same machine, so "host audio" is the user's own mic/speakers. Calls RING and are answered explicitly — a visor never streams its mic without an Answer.
Gated behind --pair-enable (needs the visor RPC); every endpoint 503s when the RPC is down or voice is disabled (no dmsg / built without audio), so the UI can hide the call controls.
Package commands cmd/apps/skychat/commands/voice_calllog.go c4-app-chat
GET /calls — the call log behind the UI's Calls tab.
There is no call-log store. Every call is already written into its conversation as a message (voice_missed.go), so the log is those messages filtered back out by their handset prefix — which means it persists, prunes, and survives a restart exactly like the rest of the history, with no second schema to keep in step. The cost is one scan of the recent window per open of the tab, which is a bounded read of a local bolt bucket.
Package commands cmd/apps/skychat/commands/voice_missed.go c4-app-chat
Every call becomes a MESSAGE in the conversation it belongs to, and the Calls tab is those messages read back.
A call used to be the one event skychat left no trace of: it rang in the visor, and if the user was not looking at that moment it was gone — nothing in the history, nothing to scroll back to, no way to know someone tried. Recording it as a message is what fixes that, and it costs nothing else: persistence, the SSE feed and the notification path all already work on messages, so a call inherits every one of them with no new plumbing. It is also why the call log needs no store of its own — see callLogHandler.
Watching rather than being told, because the visor offers no call events — only "who is ringing" and "who is connected" lists. A call that leaves the ringing list WITHOUT having appeared in the active list was not answered; one that passed through the active list was. Two seconds of resolution is ample for something whose ring lasts thirty.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var RootCmd = &cobra.Command{ Use: "skychat", Short: "skywire chat application", Long: calvin.AsciiFont("skychat"), SilenceErrors: true, SilenceUsage: true, DisableSuggestions: true, DisableFlagsInUseLine: true, Version: buildinfo.Version(), Run: func(_ *cobra.Command, args []string) { ctx := context.Background() if err := RunSkychat(ctx, args); err != nil { log.Fatal(err) } }, }
RootCmd is the root command for skywire-cli
Functions ¶
Types ¶
This section is empty.