diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..9570b8e2b0cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Go Backend Development (in `/go/` directory) +```bash +# Build +go install -tags production github.com/keybase/client/go/keybase + +# Test +make test # Run all tests +make testclean # Clean test environment +make cover # Run tests with coverage + +# Lint & Format +make fmt # Format Go code +make vet # Run go vet +make golangci-lint-nonkbfs # Lint non-KBFS code +make golangci-lint-kbfs # Lint KBFS code + +# Service +keybase service # Run local service +keybase ctl --help # Control service +``` + +### React/React Native Development (in `/shared/` directory) +```bash +# Desktop Development +yarn start # Start desktop app in dev mode +yarn start-hot # Start with hot reloading +yarn build-dev # Build development version +yarn build-prod # Build production version + +# Mobile Development +yarn rn-start # Start React Native packager +yarn rn-gobuild-ios # Build Go components for iOS +yarn rn-gobuild-android # Build Go components for Android +yarn pod-install # Install iOS CocoaPods + +# Code Quality +yarn lint # ESLint with TypeScript +yarn prettier-write-all # Format all code +yarn tsc # TypeScript compilation check +yarn coverage # TypeScript coverage +``` + +### Protocol Generation (in `/protocol/` directory) +```bash +make build # Generate protocol files from AVDL +make clean # Clean generated files +``` + +## Architecture Overview + +**Keybase is a cryptographic communication platform with a service-based architecture:** + +1. **Local Service Pattern**: A persistent Go service runs locally, handling all cryptographic operations, user management, and data storage. Multiple clients (CLI, desktop GUI, mobile apps) connect to this single service via RPC. + +2. **Protocol-Driven Communication**: All inter-component communication uses AVDL (Avro IDL) defined protocols in `/protocol/`. These definitions auto-generate code for Go, TypeScript, and other languages, ensuring type-safe RPC across all platforms. + +3. **Component Structure**: + - `/go/`: Core service implementation including crypto operations, chat backend, KBFS (encrypted filesystem), Git integration, and Stellar wallet + - `/shared/`: React-based frontend code shared between desktop (Electron) and mobile (React Native) apps + - `/protocol/`: AVDL protocol definitions that generate bindings for all platforms + - `/osx/`, `/android/`, `/ios/`: Platform-specific native code and build configurations + +4. **Key Design Patterns**: + - **Monorepo**: All platforms developed in single repository for consistency + - **Code Generation**: Protocol definitions generate type-safe bindings automatically + - **Service Abstraction**: Frontend apps are thin clients; all business logic in Go service + - **Cross-Platform Code Sharing**: React components shared between desktop and mobile + +5. **Security Architecture**: + - All cryptographic operations handled by Go service (never in frontend) + - Local key storage with platform-specific secure storage integration + - Export-controlled cryptographic software with code signing for releases + +When making changes: +- Protocol changes require regenerating bindings (`make build` in `/protocol/`) +- Go service changes may require restarting the service +- Frontend changes in `/shared/` affect both desktop and mobile apps +- Always run appropriate linters before committing (Go: `make fmt vet`, JS/TS: `yarn lint`) \ No newline at end of file diff --git a/browser/js/content.js b/browser/js/content.js index 4a811cc4e691..734940e3b6da 100644 --- a/browser/js/content.js +++ b/browser/js/content.js @@ -6,8 +6,6 @@ const bel = bundle.bel; const morphdom = bundle.morphdom; const asset = chrome.runtime.getURL; -let spaMonitorTrailer = false; - function init() { chrome.storage.sync.get(function(options) { if (options === undefined) { @@ -15,29 +13,76 @@ function init() { options = {}; } if (location.hostname.endsWith('twitter.com') || location.hostname.endsWith('facebook.com')) { - // SPA hack: Monitor location for changes and re-init. Twitter and Facebook - // are single-page-apps that don't often don't refresh when navigating - // so that makes it difficult to hook into. - // The hack is to poll every second and if the location changed - // re-scan to place the button. - // Facebook is so slow to load after the location change that for each - // location we re-init when noticed and 1s later. - // FIXME: This is sad. An alternative would be very desirable. - // Subscribing to `popstate` does not work. - let loc = window.location.pathname; - function spaMonitor() { - if (spaMonitorTrailer || window.location.pathname != loc) { - // Path changed (or trailer), force fresh init - spaMonitorTrailer = !spaMonitorTrailer; - init(); - return + // Monitor SPAs for navigation changes using MutationObserver instead of polling + let currentPath = window.location.pathname; + + // Watch for URL changes using MutationObserver on document.body + const observer = new MutationObserver(() => { + if (window.location.pathname !== currentPath) { + currentPath = window.location.pathname; + // Debounce the re-initialization to avoid multiple triggers + clearTimeout(observer.reinitTimeout); + observer.reinitTimeout = setTimeout(() => { + init(); + // For Facebook's slow loading, trigger again after a delay + if (location.hostname.endsWith('facebook.com')) { + setTimeout(init, 1000); + } + }, 100); } - // We use RAF to avoid spamming checks when the tab is not active. - requestAnimationFrame(function() { - setTimeout(spaMonitor, 1000); + }); + + // Start observing when body is available + if (document.body) { + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: false, + characterData: false }); + } else { + // Wait for body to be available + const bodyWatcher = new MutationObserver(() => { + if (document.body) { + bodyWatcher.disconnect(); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: false, + characterData: false + }); + } + }); + bodyWatcher.observe(document.documentElement, {childList: true}); } - setTimeout(spaMonitor, 1000); + + // Also listen to popstate and pushstate events as backup + window.addEventListener('popstate', () => { + if (window.location.pathname !== currentPath) { + currentPath = window.location.pathname; + init(); + } + }); + + // Intercept pushState and replaceState + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + + history.pushState = function() { + originalPushState.apply(history, arguments); + if (window.location.pathname !== currentPath) { + currentPath = window.location.pathname; + setTimeout(init, 100); + } + }; + + history.replaceState = function() { + originalReplaceState.apply(history, arguments); + if (window.location.pathname !== currentPath) { + currentPath = window.location.pathname; + setTimeout(init, 100); + } + }; } const user = matchService(window.location, document); diff --git a/go/client/cmd_currency.go b/go/client/cmd_currency.go index 00ab72b9eb4e..4b692bc6c0cf 100644 --- a/go/client/cmd_currency.go +++ b/go/client/cmd_currency.go @@ -4,7 +4,6 @@ package client import ( - "errors" "fmt" "github.com/keybase/cli" @@ -33,36 +32,6 @@ func NewCmdCurrency(cl *libcmdline.CommandLine, g *libkb.GlobalContext) cli.Comm } } -func NewCmdBTC(cl *libcmdline.CommandLine, g *libkb.GlobalContext) cli.Command { - return cli.Command{ - Name: "btc", - Action: func(c *cli.Context) { - cl.ChooseCommand(NewCmdBTCRunner(g), "btc", c) - }, - } -} - -type CmdBTC struct { - libkb.Contextified -} - -func NewCmdBTCRunner(g *libkb.GlobalContext) *CmdBTC { - return &CmdBTC{ - Contextified: libkb.NewContextified(g), - } -} - -func (c *CmdBTC) Run() (err error) { - return errors.New("this command is deprecated; use `keybase currency add` instead") -} - -func (c *CmdBTC) GetUsage() libkb.Usage { - return libkb.Usage{} -} - -func (c *CmdBTC) ParseArgv(ctx *cli.Context) error { - return nil -} func (c *CmdCurrencyAdd) ParseArgv(ctx *cli.Context) error { if len(ctx.Args()) != 1 { @@ -72,7 +41,7 @@ func (c *CmdCurrencyAdd) ParseArgv(ctx *cli.Context) error { c.force = ctx.Bool("force") w := ctx.String("type") if !(w == "bitcoin" || w == "zcash" || w == "") { - return fmt.Errorf("Bad address type; can only handle 'zcash' or 'bitcoin") + return fmt.Errorf("Bad address type; can only handle 'zcash' or 'bitcoin'") } c.wanted = w return nil diff --git a/go/client/commands_common.go b/go/client/commands_common.go index 04b40e507ca5..62bbbd028296 100644 --- a/go/client/commands_common.go +++ b/go/client/commands_common.go @@ -19,7 +19,6 @@ func GetCommands(cl *libcmdline.CommandLine, g *libkb.GlobalContext) []cli.Comma NewCmdBase62(cl, g), NewCmdBlocks(cl, g), NewCmdBot(cl, g), - NewCmdBTC(cl, g), NewCmdCA(cl, g), NewCmdChat(cl, g), NewCmdCompatDir(cl, g), diff --git a/go/teams/member_set.go b/go/teams/member_set.go index d00d63463a26..86ea3009f2d5 100644 --- a/go/teams/member_set.go +++ b/go/teams/member_set.go @@ -76,17 +76,27 @@ func newMemberSetChange(ctx context.Context, g *libkb.GlobalContext, req keybase } func (m *memberSet) recipientUids() []keybase1.UID { - uids := make([]keybase1.UID, 0, len(m.recipients)) + if len(m.recipients) == 0 { + return nil + } + uids := make([]keybase1.UID, len(m.recipients)) + i := 0 for uv := range m.recipients { - uids = append(uids, uv.Uid) + uids[i] = uv.Uid + i++ } return uids } func (m *memberSet) restrictedBotRecipientUids() []keybase1.UID { - uids := make([]keybase1.UID, 0, len(m.restrictedBotRecipients)) + if len(m.restrictedBotRecipients) == 0 { + return nil + } + uids := make([]keybase1.UID, len(m.restrictedBotRecipients)) + i := 0 for uv := range m.restrictedBotRecipients { - uids = append(uids, uv.Uid) + uids[i] = uv.Uid + i++ } return uids } diff --git a/rnmodules/react-native-drop-view/src/index.tsx b/rnmodules/react-native-drop-view/src/index.tsx index be1fdabf9987..d12de63189f6 100644 --- a/rnmodules/react-native-drop-view/src/index.tsx +++ b/rnmodules/react-native-drop-view/src/index.tsx @@ -17,7 +17,7 @@ const DropViewWrapper = (p: Props) => { }, new Array()) onDropped(cleanedUp) } catch (e) { - console.log('drop view error', e) + // Silently handle drop errors - items will be empty } }, [onDropped] diff --git a/shared/app/runtime-stats.tsx b/shared/app/runtime-stats.tsx index 5369d8b8955a..6b718083c884 100644 --- a/shared/app/runtime-stats.tsx +++ b/shared/app/runtime-stats.tsx @@ -54,28 +54,6 @@ const dbTypeString = (s: T.RPCGen.DbType) => { } } -// let destroyRadar: (() => void) | undefined -// let radarNode: HTMLDivElement | undefined -// const radarSize = 30 - -// const makeRadar = (show: boolean) => { -// if (destroyRadar) { -// destroyRadar() -// destroyRadar = undefined -// } -// if (!radarNode || !show) { -// return -// } - -// destroyRadar = lagRadar({ -// frames: 5, -// inset: 1, -// parent: radarNode, -// size: radarSize, -// speed: 0.0017 * 0.7, -// }) -// } - // simple bucketing of incoming log lines, we have a queue of incoming items, we bucket them // and choose a max to show. We use refs a lot since we only want to figure stuff out based on an interval // TODO mobile @@ -206,20 +184,6 @@ const LogStats = (props: {num?: number}) => { } const RuntimeStatsDesktop = ({stats}: Props) => { - // const [showRadar, setShowRadar] = React.useState(false) - // const refContainer = React.useCallback( - // node => { - // radarNode = node - // makeRadar(showRadar) - // }, - // [showRadar] - // ) - // const toggleRadar = () => { - // const show = !showRadar - // setShowRadar(show) - // makeRadar(show) - // } - const [moreLogs, setMoreLogs] = React.useState(false) return ( diff --git a/shared/chat/conversation/messages/system-git-push/index.tsx b/shared/chat/conversation/messages/system-git-push/index.tsx index 6a5426baedd2..6b9b17b6c67d 100644 --- a/shared/chat/conversation/messages/system-git-push/index.tsx +++ b/shared/chat/conversation/messages/system-git-push/index.tsx @@ -33,6 +33,30 @@ const GitPushCreate = (props: CreateProps) => { ) } +type RenameProps = { + pusher: string + repo: string + previousRepoName: string + repoID: string + team: string + onViewGitRepo: Props['onViewGitRepo'] + you: Props['you'] +} +const GitPushRename = (props: RenameProps) => { + const {you, pusher, repo, previousRepoName, repoID, team, onViewGitRepo} = props + return ( + + {pusher === you ? 'You ' : ''}renamed the team repository from{` `} + {previousRepoName} + {` to `} + onViewGitRepo(repoID, team) : undefined}> + {repo} + + . + + ) +} + type PushDefaultProps = { pusher: string commitRef: T.RPCGen.GitRefMetadata @@ -96,7 +120,7 @@ type PushCommonProps = { const GitPushCommon = ({children}: PushCommonProps) => {children} const GitPush = React.memo(function GitPush(p: Props) { - const {repo, repoID, refs, pushType, pusher, team} = p.message + const {repo, repoID, refs, pushType, pusher, team, previousRepoName} = p.message const gitType = T.RPCGen.GitPushType[pushType] switch (gitType) { @@ -136,7 +160,20 @@ const GitPush = React.memo(function GitPush(p: Props) { /> ) - // FIXME: @Jacob - The service has not implemented 'renamerepo' yet, so we don't render anything + case 'renamerepo': + return ( + + + + ) default: return null } diff --git a/shared/common-adapters/error-boundary.tsx b/shared/common-adapters/error-boundary.tsx index 3ef6ad2a1e15..a0759af5f67b 100644 --- a/shared/common-adapters/error-boundary.tsx +++ b/shared/common-adapters/error-boundary.tsx @@ -64,7 +64,7 @@ const Fallback = ({closeOnClick, info: {name, message, stack, componentStack}, s justifyContent: 'center', }} > - Something went wrong... + An unexpected error occurred Please submit a bug report by {Styles.isMobile ? ' going into Settings / Feedback' : ' running this command in your terminal:'} diff --git a/shared/constants/provision.tsx b/shared/constants/provision.tsx index 67c54c9f5105..71a94d4f5feb 100644 --- a/shared/constants/provision.tsx +++ b/shared/constants/provision.tsx @@ -5,6 +5,7 @@ import {RPCError} from '@/util/errors' import {isMobile} from './platform' import {type CommonResponseHandler} from '../engine/types' import isEqual from 'lodash/isEqual' +import logger from '@/logger' export type Device = { deviceNumberOfType: number @@ -139,7 +140,7 @@ export const _useState = Z.createZustand((set, get) => { const _cancel = C.wrapErrors((dueToReset?: boolean) => { C.useWaitingState.getState().dispatch.clear(waitingKey) if (!dueToReset) { - console.log('Provision: cancel called while not overloaded') + logger.info('Provision: cancel called while not overloaded') } }) @@ -199,7 +200,7 @@ export const _useState = Z.createZustand((set, get) => { }) const _submitTextCode = C.wrapErrors((_code: string) => { - console.log('Provision, unwatched submitTextCode called') + logger.warn('Provision, unwatched submitTextCode called') get().dispatch.restartProvisioning() }) @@ -362,7 +363,7 @@ export const _useState = Z.createZustand((set, get) => { let cancelled = false // freeze the autosubmit for this call so changes don't affect us const {autoSubmit} = get() - console.log('Provision: startProvisioning starting with auto submit', autoSubmit) + logger.info('Provision: startProvisioning starting with auto submit', autoSubmit) const f = async () => { const isCanceled = (response: CommonResponseHandler) => { if (cancelled) { @@ -438,7 +439,7 @@ export const _useState = Z.createZustand((set, get) => { }) if (shouldAutoSubmit(!!errorMessage, {type: 'deviceName'})) { - console.log('Provision: auto submit device name') + logger.info('Provision: auto submit device name') get().dispatch.dynamic.setDeviceName?.(get().deviceName) } else { C.useRouterState.getState().dispatch.navigateAppend('setPublicName') @@ -464,7 +465,7 @@ export const _useState = Z.createZustand((set, get) => { }) if (shouldAutoSubmit(false, {devices, type: 'chooseDevice'})) { - console.log('Provision: auto submit passphrase') + logger.info('Provision: auto submit passphrase') get().dispatch.dynamic.submitDeviceSelect?.(get().codePageOtherDevice.name) } else { C.useRouterState.getState().dispatch.navigateAppend('selectOtherDevice') @@ -493,7 +494,7 @@ export const _useState = Z.createZustand((set, get) => { }) if (shouldAutoSubmit(!!retryLabel, {type: 'passphrase'})) { - console.log('Provision: auto submit passphrase') + logger.info('Provision: auto submit passphrase') get().dispatch.dynamic.setPassphrase?.(get().passphrase) } else { switch (type) { @@ -530,7 +531,7 @@ export const _useState = Z.createZustand((set, get) => { get().dispatch.resetState() } catch (_finalError) { if (!(_finalError instanceof RPCError)) { - console.log('Provision non rpc error at end?', _finalError) + logger.error('Provision non rpc error at end?', _finalError) return } const finalError = _finalError diff --git a/shared/devices/device-revoke.tsx b/shared/devices/device-revoke.tsx index 39cc09278541..839bebc2f8cf 100644 --- a/shared/devices/device-revoke.tsx +++ b/shared/devices/device-revoke.tsx @@ -3,6 +3,7 @@ import * as Constants from '@/constants/devices' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' +import logger from '@/logger' type OwnProps = {deviceID: string} @@ -78,7 +79,7 @@ const loadEndangeredTLF = async (actingDevice: string, targetDevice: string) => ) return tlfs.endangeredTLFs?.map(t => t.name) ?? [] } catch (e) { - console.error(e) + logger.error('Failed to get revoke warning:', e) } return [] } diff --git a/shared/engine/index-impl.tsx b/shared/engine/index-impl.tsx index 81f028a91bb4..8aca88ea4f97 100644 --- a/shared/engine/index-impl.tsx +++ b/shared/engine/index-impl.tsx @@ -45,6 +45,8 @@ class Engine { _hasConnected: boolean = isMobile // mobile is always connected // App tells us when the listeners are done loading so we can start emitting events _listenersAreReady: boolean = false + // Debug interval for tracking outstanding RPCs + _debugIntervalID: NodeJS.Timeout | null = null _emitWaiting: (changes: BatchParams) => void @@ -90,7 +92,7 @@ class Engine { // Print out any alive sessions periodically if (printOutstandingRPCs) { - setInterval(() => { + this._debugIntervalID = setInterval(() => { if (Object.keys(this._sessionsMap).filter(k => !this._sessionsMap[k]?.getDangling()).length) { logger.localLog('outstandingSessionDebugger: ', this._sessionsMap) } @@ -98,6 +100,13 @@ class Engine { } } + _cleanupDebugging() { + if (this._debugIntervalID) { + clearInterval(this._debugIntervalID) + this._debugIntervalID = null + } + } + _onDisconnect() { // tell renderer we're disconnected this._onConnectedCB(false) @@ -270,6 +279,7 @@ class Engine { if (isMobile) { return } + this._cleanupDebugging() resetClient(this._rpcClient) } } diff --git a/shared/incoming-share/index.tsx b/shared/incoming-share/index.tsx index 62749009dabc..016e25e8306b 100644 --- a/shared/incoming-share/index.tsx +++ b/shared/incoming-share/index.tsx @@ -249,7 +249,7 @@ const IncomingShareError = () => { }} > - Whoops! Something went wrong. + Failed to process the shared content. diff --git a/shared/login/recover-password/device-selector/container.tsx b/shared/login/recover-password/device-selector/container.tsx index 52c1936b0fdb..8ec3a207b115 100644 --- a/shared/login/recover-password/device-selector/container.tsx +++ b/shared/login/recover-password/device-selector/container.tsx @@ -1,5 +1,6 @@ import * as C from '@/constants' import {SelectOtherDevice} from '@/provision/select-other-device' +import logger from '@/logger' const ConnectedDeviceSelector = () => { const devices = C.useRecoverState(s => s.devices) @@ -15,7 +16,7 @@ const ConnectedDeviceSelector = () => { if (submitDeviceSelect) { submitDeviceSelect(name) } else { - console.log('Missing device select?') + logger.error('submitDeviceSelect handler is missing') } } const props = { diff --git a/shared/login/signup/error.tsx b/shared/login/signup/error.tsx index 656b3870fee7..6339d8778526 100644 --- a/shared/login/signup/error.tsx +++ b/shared/login/signup/error.tsx @@ -6,11 +6,11 @@ const ConnectedSignupError = () => { const error = C.useSignupState(s => s.signupError) const goBackAndClearErrors = C.useSignupState(s => s.dispatch.goBackAndClearErrors) const onBack = goBackAndClearErrors - let header = 'Ah Shoot! Something went wrong, try again?' - let body = error ? error.desc : '' + let header = 'Sign up failed' + let body = error ? error.desc : 'Please try again.' if (!!error && C.isNetworkErr(error.code)) { - header = 'Hit an unexpected error; try again?' - body = 'This might be due to a bad connection.' + header = 'Connection error' + body = 'Unable to reach the server. Please check your internet connection and try again.' } const props = { body, diff --git a/shared/provision/error.tsx b/shared/provision/error.tsx index aaff6f9a567b..1a99a6328685 100644 --- a/shared/provision/error.tsx +++ b/shared/provision/error.tsx @@ -55,7 +55,7 @@ const Wrapper = (p: {onBack: () => void; children: React.ReactNode}) => ( - Oops, something went wrong. + An error occurred during setup {p.children} diff --git a/shared/teams/add-members-wizard/add-contacts.native.tsx b/shared/teams/add-members-wizard/add-contacts.native.tsx index 986267f6193b..de7e36b12e25 100644 --- a/shared/teams/add-members-wizard/add-contacts.native.tsx +++ b/shared/teams/add-members-wizard/add-contacts.native.tsx @@ -6,6 +6,7 @@ import * as T from '@/constants/types' import {pluralize} from '@/util/string' import {ModalTitle} from '../common' import ContactsList, {useContacts, EnableContactsPopup, type Contact} from '../common/contacts-list.native' +import logger from '@/logger' const AddContacts = () => { const nav = Container.useSafeNavigation() @@ -52,7 +53,7 @@ const AddContacts = () => { } }, err => { - console.warn(err) + logger.warn('Failed to select contacts:', err) } ) }