├── .gitignore ├── README.md ├── build ├── demo.gif └── gmi100.c /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | gmi100 3 | .dir-locals.el 4 | .gmi100 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | gmi100 2 | ====== 3 | 4 | Gemini CLI protocol client written in 100 lines of ANSI C. 5 | 6 | ![demo.gif](demo.gif) 7 | 8 | Other similar Gemini client projects written in few lines of code 9 | successfully shows how simple Gemini protocol is. This code is far 10 | from straight forward. But I had a different goal in mind. 11 | 12 | I tried to pack as much as possible in 100 lines of ANSI C. Initially 13 | I struggled to fit simple TLS connection in such small space but 14 | eventually I ended up with CLI client capable of efficient navigation 15 | between capsules of Gemini space 🚀 16 | 17 | [Discussion on Hacker News][3] 18 | 19 | Build, run and usage 20 | -------------------- 21 | 22 | Run `build` script or use any C compiler and link with [OpenSSL][0]. 23 | 24 | $ ./build # Compile on Linux 25 | $ ./gmi100 # Run with default "less -XI" pager 26 | $ ./gmi100 more # Run using "more" pager 27 | $ ./gmi100 cat # Run using "cat" as pager 28 | 29 | gmi100> gemini.circumlunar.space 30 | 31 | In `gmi100>` prompt you can take few actions: 32 | 33 | 1. Type Gemini URL to visit specific site. 34 | 2. Type a number of link on current capsule, for example: `12`. 35 | 3. Type `q` to quit. 36 | 4. Type `r` to refresh current capsule. 37 | 5. Type `u` to go "up" in URL directory path. 38 | 6. Type `b` to go back in browsing history. 39 | 7. Type `c` to print current capsule URI. 40 | 8. Type `?` to search, geminispace.info/search is used by default. 41 | 9. Type shell command prefixed with `!` to run it on current capsule. 42 | 43 | Each time you navigate to `text` document the pager program will be 44 | run with that file. By default `less -XI` is used but you can provide 45 | any other in first program argument. If your pager is interactive 46 | like less the you have to exit from that pager in order to go back to 47 | gmi100 prompt and navigate to other capsule. 48 | 49 | When non `text` file is visited, like an image or music then nothing 50 | will be displayed but temporary file will be created. Then you can 51 | use any shell command to do something with it. For example you can 52 | visit capsule with video and open it with `mpv`: 53 | 54 | gmi100> gemini://tilde.team/~konomo/noocat.webm 55 | gmi100> !mpv 56 | 57 | Or similar example with image and music. For example you can use 58 | `xdg-open` or `open` command to open file with default program for 59 | given MIME type. 60 | 61 | gmi100> gemini://158.nu/images/full/158/2022-03-13-0013_v1.jpg 62 | gmi100> !xdg-open 63 | 64 | You also can use any program on reqular text capsules. For example 65 | you decided that your defauly pager is `cat` but for some capsules you 66 | want to use `less`. Or you want to edit given page in text editor. 67 | In summary, you can open currently loaded capsule as file in any 68 | program as long as you don't navigate to other URI. 69 | 70 | gmi100> gemini.circumlunar.space 71 | gmi100> !less 72 | gmi100> !emacs 73 | gmi100> !firefox 74 | gmi100> !xdg-open 75 | 76 | 77 | How browsing history works 78 | -------------------------- 79 | 80 | Browsing history in gmi100 works differently than regular "stack" way 81 | that is commonly used in browsers and other regular modern software. 82 | It is inspired by how Emacs handles undo history. That means with the 83 | single "back" button you can go back and forward in browsing history. 84 | Also with that you will never loose any page you visited from history 85 | file and I was able to write this implementation in only few lines. 86 | 87 | After you run the program it will open or create history .gmi100 file. 88 | Then every page you visits that is not a redirection to other page and 89 | doesn't ask you for input will be appended at the end of history file. 90 | File is never cleaned up by program itself to make history persistent 91 | between sessions but that means cleaning up browsing history is your 92 | responsibility. But this also gives you an control over history file 93 | content. You can for example append some links that you want to visit 94 | in next session to have easier access to them just by running program 95 | and pressing "b" which will navigate to last link from history file. 96 | 97 | During browsing session typing "b" in program prompt for the first 98 | time will result in navigation to last link in history file. Then if 99 | you type "b" again it will open second to last link from history. But 100 | it will also append that link at the end. You can input "b" multiple 101 | times and it will always go back by one link in history and append it 102 | at then end of history file at the same time. Only if you decide to 103 | navigate to other page by typing URL or choosing link number you will 104 | break that cycle. Then history "pointer" will go back to the very 105 | bottom of the history file. Example: 106 | 107 | gmi100 session pos .gmi100 history file content 108 | ================== === =============================== 109 | 110 | gmi100> 111 | 112 | gmi100> tilde.pink >>> tilde.pink 113 | 114 | gmi100> 2 tilde.pink 115 | >>> tilde.pink/documentation.gmi 116 | 117 | gmi100> 2 tilde.pink 118 | tilde.pink/documentation.gmi 119 | >>> tilde.pink/docs/gemini.gmi 120 | 121 | gmi100> b tilde.pink 122 | >>> tilde.pink/documentation.gmi 123 | tilde.pink/docs/gemini.gmi 124 | tilde.pink/documentation.gmi 125 | 126 | gmi100> b >>> tilde.pink 127 | tilde.pink/documentation.gmi 128 | tilde.pink/docs/gemini.gmi 129 | tilde.pink/documentation.gmi 130 | tilde.pink 131 | 132 | gmi100> 3 tilde.pink 133 | tilde.pink/documentation.gmi 134 | tilde.pink/docs/gemini.gmi 135 | tilde.pink/documentation.gmi 136 | tilde.pink 137 | >>> gemini.circumlunar.space/ 138 | 139 | 140 | Devlog 141 | ------ 142 | 143 | ### 2023.07.11 Initial motivation and thoughts 144 | 145 | Authors of Gemini protocol claims that it should be possible to write 146 | Gemini client in modern language [in less than 100 lines of code][1]. 147 | There are few projects that do that in programming languages with 148 | garbage collectors, build in dynamic data structures and useful std 149 | libraries for string manipulation, parsing URLs etc. 150 | 151 | Intuition suggest that such achievement is not possible in plain C. 152 | Even tho I decided to start this silly project and see how far I can 153 | go with just ANSI C, std libraries and one dependency - OpenSSL. 154 | 155 | It took me around 3 weeks of lazy slow programming to get to this 156 | point but results exceeded my expectations. It turned out that it's 157 | not only achievable but also it's possible to include many convenient 158 | features like persistent browsing history, links formatting, wrapping 159 | of lines, pagination and some error handling. 160 | 161 | My goal was to write in c89 standard avoiding any dirty tricks that 162 | could buy me more lines like defining imports and constant values in 163 | compiler command or writing multiple things in single line separated 164 | with semicolon. I think that final result can be called a normal C 165 | code but OFC it is very dense, hard to read and uses practices that 166 | are normally not recommended. Even tho I call it a success. 167 | 168 | I was not able to make better line wrapping work. Ideally lines 169 | should wrap at last whitespace that fits within defined boundary and 170 | respects wide characters. The best I could do in given constrains was 171 | to do a hard line wrap after defined number of bytes. Yes - bytes, so 172 | it is possible to split wide character in half at the end of the line. 173 | It can ruin ASCII art that uses non ASCII characters and sites written 174 | mainly without ASCII characters. This is the only thing that bothers 175 | me. Line wrapping itself is very necessary to make pagination and 176 | pagination is necessary to make this program usable on terminals that 177 | does not support scrolling. Maybe it would be better to somehow 178 | integrate gmi100 with pager like "less". Then I don't have to 179 | implement pagination and line wrapping at all. That would be great. 180 | 181 | I'm very happy that I was able to make browsing history work using 182 | external file and not and array like in most small implementation I 183 | have read. With that this program is actually usable for me. I'm 184 | very happy about how the history works which is out of the ordinary 185 | but I allows to have back and forward navigation with single logic. 186 | With that I could fit 2 functionalities in single implementation. 187 | 188 | I'm also very happy about links formatting. Without this small 189 | adjustment of output text I would not like to use this program for 190 | actual browsing of Gemini space. 191 | 192 | I thought about adding "default site" being the Gemini capsule that 193 | opens by default when you run the program. But that can be easily 194 | done with small shell script or alias so I'm not going to do it. 195 | 196 | ```sh 197 | echo "some.default.page.com" | gmi100 198 | ``` 199 | 200 | I's amazing how much can fit in 100 lines of C. 201 | 202 | ### 2023.07.12 - v2.0 the pager 203 | 204 | Removing manual line wrapping and pagination in favor of pager program 205 | that can be changed at any time was a great idea. I love to navigate 206 | Gemini holes with `cat` as pager when I'm in Emacs and with `less -X` 207 | when in terminal. 208 | 209 | ### 2023.07.12 Wed 19:48 - v2.1 SSL issues and other changes 210 | 211 | After using gmi100 for some time I noticed that often you stumble upon 212 | a capsule by navigating directly to some distant path pointing at some 213 | gemlog entry. But then you want to visit home page of this author. 214 | With current setup you would had to type URL by hand if visited page 215 | did not provided handy "Go home" link. Then I recalled that many GUI 216 | browsers include "Up" and "Go home" buttons because you are able to 217 | easily modify current URI to achieve such navigation. This was 218 | trivial to add in gmi100. Required only single line that appends 219 | `../` to current URI. I added only "Up" functionality as navigation 220 | to "Home" can be achieved by using "Up" few times in row and I don't 221 | want to loose precious lines of code. 222 | 223 | More than that, I changed default pager to `less` as it provides the 224 | best experience in terminal and this is what people will use most of 225 | the time including me. For special cases in Emacs I can change pager 226 | to `cat` with ease anyway. 227 | 228 | Back to the main topic. I had troubles opening many pages from 229 | specific domains. All of those probably run on the same server. Some 230 | kind o SSL error, not very specific. I was able to open those pages 231 | with this simple line of code: 232 | 233 | ```sh 234 | $ openssl s_client -crlf -ign_eof -quiet -connect senders.io:1965 <<< "gemini://senders.io:1965/gemlog/" 235 | ``` 236 | 237 | Which means that servers work fine and there is something wrong in my 238 | code. I'm probably missing some SSL setting. 239 | 240 | ### 2023.07.13 Thu 04:56 - `SSL_ERROR_SSL` error fixed 241 | 242 | I finally found it. I had to use `SSL_set_tlsext_host_name` before 243 | establishing connection. I would not be able to figured it out by 244 | myself. All thanks to source code of project [gplaces][2]. And yes, 245 | it's 5 am. 246 | 247 | ### 2023.07.18 Tue 16:42 - v3.0 I am complete! \m/ 248 | 249 | In v3 I completely redesigned core memory handling by switching to 250 | files only. With that program is now able to handle non text capsules 251 | that contains images, music, videos and other. 252 | 253 | In simpler words, server response body is always stored as temporary 254 | file. This file is then passed to pager program if MIME type is of 255 | text type. Else nothing happens but you can invoke any command on 256 | this file so you can use `mpv` for media files or PDF viewer for 257 | documents etc. This also opens a lot of other possibilities. For 258 | example you can easily open currently loaded capsule in different 259 | pager than default or in text editor or you can just use your system 260 | default program with `xdg-open`. And as log as you don't navigate to 261 | other capsule you can keep using different commands on that file. 262 | 263 | I also added few small useful commands like easy searching with `?`. 264 | I was trying really hard to also implement handling for local files 265 | with `file://` prefix. But I would have to make links parser somehow 266 | generic. Right now it depends on SSL functions. I don't see how to 267 | fit that in current code structure. I'm not planning any further 268 | development. I already achieved much more than I initially wanted. 269 | 270 | I'm calling this project complete. 271 | 272 | > I am complete! 273 | > Ha-aaaack 274 | > Yes, you are hacked 275 | > Overflow stack 276 | > Now I'm complete 277 | > And my log you debug 278 | > This code will be mine 279 | > #include in first line 280 | > 281 | > And now your shell compile 282 | 283 | ### 2023.07.24 Mon 17:53 - Feedback from interwebs 284 | 285 | During [discussion on Hacker News][3] one user pointed out critical 286 | bugs and potential errors in code. Corrections are committed. 287 | Everyone should be safe now from buffer underflow and memory leak so 288 | please disperse as there is nothing to see here and please don't tell 289 | Rust community about it. 290 | 291 | ### 2023.09.24 Sun 04:11 - Joining the big boys 292 | 293 | Thanks to [Solderpunk][4] ([web][5]) at 21st of September project 294 | `gmi100` was added to list of Gemini software on official protocol 295 | [capsule][6] (and [website][7]). I'm so proud of my little boy. 296 | 297 | 298 | [0]: https://www.openssl.org/ 299 | [1]: https://gemini.circumlunar.space/docs/faq.gmi 300 | [2]: https://github.com/dimkr/gplaces/blob/gemini/gplaces.c#L841 301 | [3]: https://news.ycombinator.com/item?id=36786239 302 | [4]: gemini://zaibatsu.circumlunar.space/~solderpunk/ 303 | [5]: https://zaibatsu.circumlunar.space/~solderpunk/ 304 | [6]: gemini://geminiprotocol.net/software/ 305 | [7]: https://geminiprotocol.net/software/ 306 | -------------------------------------------------------------------------------- /build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -ex # Stop on first error and log all commands 3 | 4 | gcc \ 5 | -Wall -Wextra -Wno-misleading-indentation -pedantic -std=c89 \ 6 | -o gmi100 gmi100.c \ 7 | -lssl -lcrypto 8 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ir33k/gmi100/a4bea88b2e5b429767014da7fa81ed5ec5af06b5/demo.gif -------------------------------------------------------------------------------- /gmi100.c: -------------------------------------------------------------------------------- 1 | #include /* Gemini CLI protocol client written in 100 lines of C */ 2 | #include /* v3.2 https://github.com/ir33k/gmi100 by irek@gabr.pl */ 3 | #include /* This is free and unencumbered software released into */ 4 | #include /* the public domain. Read more: https://unlicense.org */ 5 | #include 6 | #include 7 | #define WARN(msg) do { fputs("WARNING: "msg"\n", stderr); goto start; } while(0) 8 | 9 | int main(int argc, char **argv) { 10 | char uri[1024+1], buf[4096], buf2[2048], *bp, *t=tmpnam(0); 11 | int i, j, siz, sfd=0, hp, KB=1024; 12 | FILE *his, *tmp; 13 | struct hostent *he; 14 | struct sockaddr_in addr; 15 | SSL_CTX *ctx; 16 | SSL *ssl=0; 17 | addr.sin_family=AF_INET, addr.sin_port=htons(1965); /* Gemini port */ 18 | SSL_library_init(); 19 | if (!(ctx = SSL_CTX_new(TLS_client_method()))) errx(1, "SSL_CTX_new"); 20 | if (!(his = fopen(".gmi100", "a+b"))) err(1, "fopen(.gmi100)"); 21 | if (!(tmp = fopen(t, "w+b"))) err(1, "fopen(%s)", t); 22 | fseek(his, 0, SEEK_END), hp = ftell(his)-1; 23 | start: fprintf(stderr, "gmi100> "); /* Main loop */ 24 | if (!fgets(buf, KB, stdin)) return 0; 25 | if (*buf == '!') { /* Cmd */ 26 | buf[strlen(buf)-1] = 0; 27 | sys: sprintf(buf2, "%.256s %s", buf+1, t); 28 | system(buf2); 29 | goto start; 30 | } else if (buf[1]=='\n') switch (buf[0]) { /* Commands */ 31 | case 'q': return 0; 32 | case '?': sprintf(uri, "geminispace.info/search"); goto query; 33 | case 'r': strcpy(buf, uri); goto uri; /* Refresh */ 34 | case 'c': printf("%s\n", uri); goto start; 35 | case 'u': sprintf(buf, "%.1021s../", uri); goto uri; 36 | case 'b': while (!fseek(his, --hp, 0) && hp && fgetc(his)!='\n'); 37 | fgets(buf, KB, his); 38 | goto uri; 39 | } 40 | hp = ftell(his)-1; /* Reset history position */ 41 | if ((i=atoi(buf)) > 0 && (siz=sprintf(buf, "[%d]\t=> ", i))) { /* Nav */ 42 | rewind(tmp); 43 | for (bp=buf2; fgets(bp, siz+1, tmp) && (i=strcmp(bp, buf));); 44 | if (i) goto start; 45 | fgets(bp, KB, tmp); 46 | if (strstr(bp, "//")) uri[0] = 0; /* Absolute */ 47 | else if (bp[0] == '/') uri[strcspn(uri, "/\n\0")] = 0; 48 | else if (!strncmp(&bp[i], "../", 2)); /* Keep whole uri */ 49 | else if (!strchr(uri, '/')) strcat(uri, "/"); 50 | else for(j = strlen(uri); j && uri[--j] != '/'; uri[j] = 0); 51 | sprintf(buf, "%s%s", uri, bp); 52 | } /* Typed URL */ 53 | uri: i = strstr(buf, "//") ? (strncmp(buf, "gemini:", 7) ? 2 : 9) : 0; 54 | for (j=strlen(buf)-1; j>=0 && buf[j] <= ' '; j--); /* Trim */ 55 | for (buf[j+1]=0, j=0, uri[0]=0; buf[i] && jh_addr_list[i]; i++) { 66 | memcpy(&addr.sin_addr.s_addr, he->h_addr_list[i], sizeof(in_addr_t)); 67 | j = connect(sfd, (struct sockaddr*)&addr, sizeof(addr)); 68 | } 69 | if (j) WARN("Failed to connect"); 70 | siz = sprintf(buf2, "gemini://%.*s\r\n", KB, uri); 71 | if (ssl) SSL_free(ssl); 72 | if ((ssl = SSL_new(ctx)) == 0) WARN("SSL_new"); 73 | if (!SSL_set_tlsext_host_name(ssl, buf)) WARN("SSL_set_tlsext"); 74 | if (!SSL_set_fd(ssl, sfd)) WARN("SSL_set_fd"); 75 | if (SSL_connect(ssl) < 1) WARN("SSL_connect"); 76 | if (SSL_write(ssl, buf2, siz) < 1) WARN("SSL_write"); 77 | for (i=0, bp=buf2; SSL_read(ssl, bp, 1) && *bp != '\n'; bp+=1, bp[1]=0); 78 | if ((bp=buf2) && bp[0] == '1') { /* Query */ 79 | query: siz = sprintf(buf, "%.*s?", (int)strcspn(uri, "?\0"), uri); 80 | printf("Query: "); 81 | fgets(buf+siz, KB-siz, stdin); 82 | goto uri; 83 | } else if (bp[0] == '3' && strcpy(buf, bp+3)) goto uri; /* Redirect */ 84 | fprintf(his, "%s\n", uri); /* Save URI in history */ 85 | if (!(tmp = freopen(t, "w+b", tmp))) err(1, "freopen(%s)", t); 86 | nodesc: if ((j=!strncmp(bp+3, "text/", 5))) while (SSL_peek(ssl, bp, 2)) { 87 | if (!strncmp(bp, "=>", 2) && SSL_read(ssl, bp, 2)) { 88 | fprintf(tmp, "[%d]\t=> ", ++i); /* It's-a Mee, URIoo! */ 89 | while (SSL_peek(ssl,bp,1)&&*bp<=' ') SSL_read(ssl,bp,1); 90 | while (SSL_read(ssl,bp,1)&& fputc(*bp,tmp) &&*bp> ' '); 91 | if (*bp == '\n') goto nodesc; /* URI without descri */ 92 | fputs("\n\t", tmp); 93 | while (SSL_peek(ssl,bp,1)&&*bp<=' ') SSL_read(ssl,bp,1); 94 | } while (SSL_read(ssl, bp, 1) && fputc(*bp, tmp) && *bp!='\n'); 95 | } else while ((siz=SSL_read(ssl, bp, KB))) fwrite(bp, 1, siz, tmp); 96 | if (fflush(tmp)) err(1, "fflush(tmp)"); 97 | if (j && sprintf(buf, "?%.256s", argc>1?argv[1]:"less -XI")) goto sys; 98 | goto start; 99 | return 0; 100 | } 101 | --------------------------------------------------------------------------------