api.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. // License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
  2. package shell_integration
  3. import (
  4. "archive/tar"
  5. "bytes"
  6. "fmt"
  7. "os"
  8. "os/exec"
  9. "path/filepath"
  10. "strings"
  11. "golang.org/x/exp/maps"
  12. "golang.org/x/exp/slices"
  13. "kitty"
  14. "kitty/tools/tty"
  15. "kitty/tools/utils"
  16. )
  17. var _ = fmt.Print
  18. type integration_setup_func = func(shell_integration_dir string, argv []string, env map[string]string) ([]string, map[string]string, error)
  19. func TerminfoData() string {
  20. d := Data()
  21. entry := d["terminfo/x/xterm-kitty"]
  22. return utils.UnsafeBytesToString(entry.Data)
  23. }
  24. func extract_files(match, dest_dir string) (err error) {
  25. d := Data()
  26. for _, fname := range d.FilesMatching(match) {
  27. entry := d[fname]
  28. dest := filepath.Join(dest_dir, fname)
  29. ddir := filepath.Dir(dest)
  30. if err = os.MkdirAll(ddir, 0o755); err != nil {
  31. return
  32. }
  33. switch entry.Metadata.Typeflag {
  34. case tar.TypeDir:
  35. if err = os.MkdirAll(dest, 0o755); err != nil {
  36. return
  37. }
  38. case tar.TypeSymlink:
  39. if err = os.Symlink(entry.Metadata.Linkname, dest); err != nil {
  40. return
  41. }
  42. case tar.TypeReg:
  43. if existing, rerr := os.ReadFile(dest); rerr == nil && bytes.Equal(existing, entry.Data) {
  44. continue
  45. }
  46. if err = utils.AtomicWriteFile(dest, entry.Data, 0o644); err != nil {
  47. return
  48. }
  49. }
  50. }
  51. return
  52. }
  53. func extract_shell_integration_for(shell_name string, dest_dir string) (err error) {
  54. return extract_files("shell-integration/"+shell_name+"/", dest_dir)
  55. }
  56. func extract_terminfo(dest_dir string) (err error) {
  57. var s os.FileInfo
  58. if s, err = os.Stat(filepath.Join(dest_dir, "terminfo", "x", kitty.DefaultTermName)); err == nil && s.Mode().IsRegular() {
  59. if s, err = os.Stat(filepath.Join(dest_dir, "terminfo", "78", kitty.DefaultTermName)); err == nil && s.Mode().IsRegular() {
  60. return
  61. }
  62. }
  63. if err = extract_files("terminfo/", dest_dir); err == nil {
  64. dest := filepath.Join(dest_dir, "terminfo", "78")
  65. err = os.Symlink("x", dest)
  66. }
  67. return
  68. }
  69. func PathToTerminfoDb(term string) (ans string) {
  70. // see man terminfo for the algorithm ncurses uses for this
  71. seen := utils.NewSet[string]()
  72. check_dir := func(path string) string {
  73. if seen.Has(path) {
  74. return ``
  75. }
  76. seen.Add(path)
  77. q := filepath.Join(path, term[:1], term)
  78. if s, err := os.Stat(q); err == nil && s.Mode().IsRegular() {
  79. return q
  80. }
  81. if entries, err := os.ReadDir(filepath.Join(path)); err == nil {
  82. for _, x := range entries {
  83. q := filepath.Join(path, x.Name(), term)
  84. if s, err := os.Stat(q); err == nil && s.Mode().IsRegular() {
  85. return q
  86. }
  87. }
  88. }
  89. return ``
  90. }
  91. if td := os.Getenv("TERMINFO"); td != "" {
  92. if ans = check_dir(td); ans != "" {
  93. return ans
  94. }
  95. }
  96. if ans = check_dir(utils.Expanduser("~/.terminfo")); ans != "" {
  97. return ans
  98. }
  99. if td := os.Getenv("TERMINFO_DIRS"); td != "" {
  100. for _, q := range strings.Split(td, string(os.PathListSeparator)) {
  101. if q == "" {
  102. q = "/usr/share/terminfo"
  103. }
  104. if ans = check_dir(q); ans != "" {
  105. return ans
  106. }
  107. }
  108. }
  109. for _, q := range []string{"/usr/share/terminfo", "/usr/lib/terminfo", "/usr/share/lib/terminfo"} {
  110. if ans = check_dir(q); ans != "" {
  111. return ans
  112. }
  113. }
  114. return
  115. }
  116. func EnsureTerminfoFiles() (terminfo_dir string, err error) {
  117. if kid := os.Getenv("KITTY_INSTALLATION_DIR"); kid != "" {
  118. if s, e := os.Stat(kid); e == nil && s.IsDir() {
  119. q := filepath.Join(kid, "terminfo")
  120. if s, e := os.Stat(q); e == nil && s.IsDir() {
  121. return q, nil
  122. }
  123. }
  124. }
  125. base := filepath.Join(utils.CacheDir(), "extracted-kti")
  126. if err = os.MkdirAll(base, 0o755); err != nil {
  127. return "", err
  128. }
  129. if err = extract_terminfo(base); err != nil {
  130. return "", fmt.Errorf("Failed to extract terminfo files with error: %w", err)
  131. }
  132. return filepath.Join(base, "terminfo"), nil
  133. }
  134. func EnsureShellIntegrationFilesFor(shell_name string) (shell_integration_dir_for_shell string, err error) {
  135. if kid := os.Getenv("KITTY_INSTALLATION_DIR"); kid != "" {
  136. if s, e := os.Stat(kid); e == nil && s.IsDir() {
  137. q := filepath.Join(kid, "shell-integration", shell_name)
  138. if s, e := os.Stat(q); e == nil && s.IsDir() {
  139. return q, nil
  140. }
  141. }
  142. }
  143. base := filepath.Join(utils.CacheDir(), "extracted-ksi")
  144. if err = os.MkdirAll(base, 0o755); err != nil {
  145. return "", err
  146. }
  147. if err = extract_shell_integration_for(shell_name, base); err != nil {
  148. return "", err
  149. }
  150. return filepath.Join(base, "shell-integration", shell_name), nil
  151. }
  152. func is_new_zsh_install(env map[string]string, zdotdir string) bool {
  153. // if ZDOTDIR is empty, zsh will read user rc files from /
  154. // if there aren't any, it'll run zsh-newuser-install
  155. // the latter will bail if there are rc files in $HOME
  156. if zdotdir == "" {
  157. if zdotdir = env[`HOME`]; zdotdir == "" {
  158. if q, err := os.UserHomeDir(); err == nil {
  159. zdotdir = q
  160. } else {
  161. return true
  162. }
  163. }
  164. }
  165. for _, q := range []string{`.zshrc`, `.zshenv`, `.zprofile`, `.zlogin`} {
  166. if _, e := os.Stat(filepath.Join(zdotdir, q)); e == nil {
  167. return false
  168. }
  169. }
  170. return true
  171. }
  172. func get_zsh_zdotdir_from_global_zshenv(argv []string, env map[string]string) string {
  173. c := exec.Command(utils.FindExe(argv[0]), `--norcs`, `--interactive`, `-c`, `echo -n $ZDOTDIR`)
  174. for k, v := range env {
  175. c.Env = append(c.Env, k+"="+v)
  176. }
  177. if raw, err := c.Output(); err == nil {
  178. return utils.UnsafeBytesToString(raw)
  179. }
  180. return ""
  181. }
  182. func zsh_setup_func(shell_integration_dir string, argv []string, env map[string]string) (final_argv []string, final_env map[string]string, err error) {
  183. zdotdir := env[`ZDOTDIR`]
  184. final_argv, final_env = argv, env
  185. if is_new_zsh_install(env, zdotdir) {
  186. if zdotdir == "" {
  187. // Try to get ZDOTDIR from /etc/zshenv, when all startup files are not present
  188. zdotdir = get_zsh_zdotdir_from_global_zshenv(argv, env)
  189. if zdotdir == "" || is_new_zsh_install(env, zdotdir) {
  190. return final_argv, final_env, nil
  191. }
  192. } else {
  193. // dont prevent zsh-newuser-install from running
  194. // zsh-newuser-install never runs as root but we assume that it does
  195. return final_argv, final_env, nil
  196. }
  197. }
  198. if zdotdir != "" {
  199. env[`KITTY_ORIG_ZDOTDIR`] = zdotdir
  200. } else {
  201. // KITTY_ORIG_ZDOTDIR can be set at this point if, for example, the global
  202. // zshenv overrides ZDOTDIR; we try to limit the damage in this case
  203. delete(final_env, `KITTY_ORIG_ZDOTDIR`)
  204. }
  205. final_env[`ZDOTDIR`] = shell_integration_dir
  206. return
  207. }
  208. func fish_setup_func(shell_integration_dir string, argv []string, env map[string]string) (final_argv []string, final_env map[string]string, err error) {
  209. shell_integration_dir = filepath.Dir(shell_integration_dir)
  210. val := env[`XDG_DATA_DIRS`]
  211. env[`KITTY_FISH_XDG_DATA_DIR`] = shell_integration_dir
  212. if val == "" {
  213. env[`XDG_DATA_DIRS`] = shell_integration_dir
  214. } else {
  215. dirs := utils.Filter(strings.Split(val, string(filepath.ListSeparator)), func(x string) bool { return x != "" })
  216. dirs = append([]string{shell_integration_dir}, dirs...)
  217. env[`XDG_DATA_DIRS`] = strings.Join(dirs, string(filepath.ListSeparator))
  218. }
  219. return argv, env, nil
  220. }
  221. var debugprintln = tty.DebugPrintln
  222. var _ = debugprintln
  223. func bash_setup_func(shell_integration_dir string, argv []string, env map[string]string) ([]string, map[string]string, error) {
  224. inject := utils.NewSetWithItems(`1`)
  225. var posix_env, rcfile string
  226. remove_args := utils.NewSet[int](8)
  227. expecting_multi_chars_opt := true
  228. var expecting_option_arg, interactive_opt, expecting_file_arg, file_arg_set bool
  229. for i := 1; i < len(argv); i++ {
  230. arg := argv[i]
  231. if expecting_file_arg {
  232. file_arg_set = true
  233. break
  234. }
  235. if expecting_option_arg {
  236. expecting_option_arg = false
  237. continue
  238. }
  239. if arg == `-` || arg == `--` {
  240. if !expecting_file_arg {
  241. expecting_file_arg = true
  242. }
  243. continue
  244. } else if len(arg) > 1 && arg[1] != '-' && (arg[0] == '-' || strings.HasPrefix(arg, `+O`)) {
  245. expecting_multi_chars_opt = false
  246. options := strings.TrimLeft(arg, `-+`)
  247. // shopt option
  248. if a, b, found := strings.Cut(options, `O`); found {
  249. if b == "" {
  250. expecting_option_arg = true
  251. }
  252. options = a
  253. }
  254. // command string
  255. if strings.ContainsRune(options, 'c') {
  256. // non-interactive shell
  257. // also skip `bash -ic` interactive mode with command string
  258. return argv, env, nil
  259. }
  260. // read from stdin and follow with args
  261. if strings.ContainsRune(options, 's') {
  262. break
  263. }
  264. // interactive option
  265. if strings.ContainsRune(options, 'i') {
  266. interactive_opt = true
  267. }
  268. } else if strings.HasPrefix(arg, `--`) && expecting_multi_chars_opt {
  269. if arg == `--posix` {
  270. inject.Add(`posix`)
  271. posix_env = env[`ENV`]
  272. remove_args.Add(i)
  273. } else if arg == `--norc` {
  274. inject.Add(`no-rc`)
  275. remove_args.Add(i)
  276. } else if arg == `--noprofile` {
  277. inject.Add(`no-profile`)
  278. remove_args.Add(i)
  279. } else if (arg == `--rcfile` || arg == `--init-file`) && i+1 < len(argv) {
  280. expecting_option_arg = true
  281. rcfile = argv[i+1]
  282. remove_args.AddItems(i, i+1)
  283. }
  284. } else {
  285. file_arg_set = true
  286. break
  287. }
  288. }
  289. if file_arg_set && !interactive_opt {
  290. // non-interactive shell
  291. return argv, env, nil
  292. }
  293. env[`ENV`] = filepath.Join(shell_integration_dir, `kitty.bash`)
  294. env[`KITTY_BASH_INJECT`] = strings.Join(inject.AsSlice(), " ")
  295. if posix_env != "" {
  296. env[`KITTY_BASH_POSIX_ENV`] = posix_env
  297. }
  298. if rcfile != "" {
  299. env[`KITTY_BASH_RCFILE`] = rcfile
  300. }
  301. sorted := remove_args.AsSlice()
  302. slices.Sort(sorted)
  303. for _, i := range utils.Reverse(sorted) {
  304. argv = slices.Delete(argv, i, i+1)
  305. }
  306. if env[`HISTFILE`] == "" && !inject.Has(`posix`) {
  307. // In POSIX mode the default history file is ~/.sh_history instead of ~/.bash_history
  308. env[`HISTFILE`] = utils.Expanduser(`~/.bash_history`)
  309. env[`KITTY_BASH_UNEXPORT_HISTFILE`] = `1`
  310. }
  311. argv = slices.Insert(argv, 1, `--posix`)
  312. if bashrc := os.Getenv(`KITTY_RUNNING_BASH_INTEGRATION_TEST`); bashrc != `` && os.Getenv("KITTY_RUNNING_SHELL_INTEGRATION_TEST") == "1" {
  313. // prevent bash from sourcing /etc/profile which is not under our control
  314. env[`KITTY_BASH_INJECT`] += ` posix`
  315. env[`KITTY_BASH_POSIX_ENV`] = bashrc
  316. }
  317. return argv, env, nil
  318. }
  319. func setup_func_for_shell(shell_name string) integration_setup_func {
  320. switch shell_name {
  321. case "zsh":
  322. return zsh_setup_func
  323. case "fish":
  324. return fish_setup_func
  325. case "bash":
  326. return bash_setup_func
  327. }
  328. return nil
  329. }
  330. func IsSupportedShell(shell_name string) bool { return setup_func_for_shell(shell_name) != nil }
  331. func Setup(shell_name string, ksi_var string, argv []string, env map[string]string) ([]string, map[string]string, error) {
  332. ksi_dir, err := EnsureShellIntegrationFilesFor(shell_name)
  333. if err != nil {
  334. return nil, nil, err
  335. }
  336. argv, env, err = setup_func_for_shell(shell_name)(ksi_dir, slices.Clone(argv), maps.Clone(env))
  337. if err == nil {
  338. env[`KITTY_SHELL_INTEGRATION`] = ksi_var
  339. }
  340. return argv, env, err
  341. }