├── LICENSE.txt ├── README.md ├── jimmy Localizations ├── en.xcloc │ ├── Localized Contents │ │ └── en.xliff │ ├── Source Contents │ │ └── jimmy │ │ │ └── en.lproj │ │ │ ├── InfoPlist.strings │ │ │ └── Localizable.strings │ └── contents.json ├── es.xcloc │ ├── Localized Contents │ │ └── es.xliff │ ├── Source Contents │ │ └── jimmy │ │ │ └── en.lproj │ │ │ ├── InfoPlist.strings │ │ │ └── Localizable.strings │ └── contents.json └── fr.xcloc │ ├── Localized Contents │ └── fr.xliff │ ├── Source Contents │ └── jimmy │ │ └── en.lproj │ │ ├── InfoPlist.strings │ │ └── Localizable.strings │ └── contents.json ├── jimmy.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ └── xcschemes │ │ ├── jimmy-prod.xcscheme │ │ └── jimmy.xcscheme └── xcuserdata │ └── jonathan.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── jimmy ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── 1024.png │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256-1.png │ │ ├── 256.png │ │ ├── 32-1.png │ │ ├── 32.png │ │ ├── 512-1.png │ │ ├── 512.png │ │ ├── 64.png │ │ └── Contents.json │ ├── Contents.json │ ├── background.colorset │ │ └── Contents.json │ ├── home.dataset │ │ ├── Contents.json │ │ └── jimmy.gmi │ ├── home_es.dataset │ │ ├── Contents.json │ │ └── jimmy.gmi │ ├── home_fr.dataset │ │ ├── Contents.json │ │ └── jimmy.gmi │ └── urlbackground.colorset │ │ └── Contents.json ├── Fonts │ └── SourceCodePro-VariableFont_wght.ttf ├── Info.plist ├── Models │ ├── Actions.swift │ ├── Bookmark.swift │ ├── Bookmarks.swift │ ├── Emojis.swift │ ├── Header.swift │ ├── History.swift │ ├── HistoryItem.swift │ ├── IgnoredCertificates.swift │ ├── Tab.swift │ └── TabHistoryItem.swift ├── Network │ ├── Client.swift │ └── ClientConnection.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Utils │ ├── ContentParser.swift │ ├── Data+Extensions.swift │ ├── Scanner+Extensions.swift │ ├── String+IDNA.swift │ ├── URLParser.swift │ ├── UTS46+Loading.swift │ ├── UTS46.swift │ └── uts46 ├── Views │ ├── AttributedText.swift │ ├── AttributedTextImpl.swift │ ├── BookmarkView.swift │ ├── BookmarksView.swift │ ├── CommandsView.swift │ ├── ContentView.swift │ ├── HistoryItemView.swift │ ├── HistoryView.swift │ ├── LineView.swift │ ├── TabContentWrapperView.swift │ ├── TabLineView.swift │ └── TabTextView.swift ├── en.lproj │ └── InfoPlist.strings ├── es.lproj │ ├── InfoPlist.strings │ └── Localizable.strings ├── fr.lproj │ └── Localizable.strings ├── jimmy.entitlements └── jimmyApp.swift ├── logo.png ├── logo.svg └── screenshots ├── darkmode.png └── lightmode.png /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2022 Jonathan Foucher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Jimmy 4 | 5 | 6 | ## A gemini client for macOS 7 | 8 | Jimmy is a native [gemini](https://en.wikipedia.org/wiki/Gemini_(protocol)) client for macOS, written in SwiftUI. It aims to be lightweight and efficient, while having a design that integrates perfectly with macOS 9 | 10 | It is currently a very early prototype, but most things are starting to work. 11 | 12 | What does work, also known as: 13 | 14 | ## Features 15 | 16 | - Multiple tabs 17 | - Display of text, links and images 18 | - Trigger download of unknown file types 19 | - Open unknown protocol by delegating to OS 20 | - Save and clear history 21 | - Search through history while typing in the URL bar 22 | - Emoji favicon for each domain (either though favicon.txt or autogenerated) 23 | - Search through content on each tab 24 | - Asks for validation of self signed certificates 25 | - Show a red lock for self-signed certificates and a green one for fully valid certificates 26 | - And more planned, see [the issues](https://github.com/jfoucher/Jimmy/issues) 27 | 28 | ## Screenshots 29 | 30 | 31 | ![Light mode](screenshots/lightmode.png) 32 | *Light mode* 33 | 34 | 35 | ![Dark mode](screenshots/darkmode.png) 36 | *Dark mode* 37 | 38 | ## Installation 39 | 40 | Download the latest release zip file from [here](https://github.com/jfoucher/Jimmy/releases/latest), unzip and place the app in your Applications folder. 41 | 42 | ## Development log 43 | I have a [devlog on gemini](gemini://gemini.6px.eu/jimmy/devlog/) 44 | -------------------------------------------------------------------------------- /jimmy Localizations/en.xcloc/Localized Contents/en.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 |
7 | 8 | 9 | Jimmy 10 | Jimmy 11 | Bundle name 12 | 13 | 14 | Copyright 2022 Jonathan Foucher 15 | Copyright 2022 Jonathan Foucher 16 | Copyright (human-readable) 17 | 18 | 19 |
20 | 21 |
22 | 23 |
24 | 25 | 26 | %d Page Not Found 27 | %d Page Not Found 28 | page not found title. First argument is the error code 29 | 30 | 31 | %d Server Error 32 | %d Server Error 33 | Generic server error title. First param is the error code 34 | 35 | 36 | Answer 37 | Answer 38 | No comment provided by engineer. 39 | 40 | 41 | Bookmarks 42 | Bookmarks 43 | No comment provided by engineer. 44 | 45 | 46 | Clear history 47 | Clear history 48 | No comment provided by engineer. 49 | 50 | 51 | Copy link address 52 | Copy link address 53 | No comment provided by engineer. 54 | 55 | 56 | Could not connect 57 | Could not connect 58 | No comment provided by engineer. 59 | 60 | 61 | Could not load %@ 62 | Could not load %@ 63 | Generic server error subtitle. First param is full url 64 | 65 | 66 | Ignore certificate validation for %1$@%2$@ 67 | Ignore certificate validation for %1$@%2$@ 68 | Button label to ignore certificate validation for this host 69 | 70 | 71 | Invalid certificate 72 | Invalid certificate 73 | No comment provided by engineer. 74 | 75 | 76 | New Tab 77 | New Tab 78 | No comment provided by engineer. 79 | 80 | 81 | Open Link in New Tab 82 | Open Link in New Tab 83 | No comment provided by engineer. 84 | 85 | 86 | Open in new tab 87 | Open in new tab 88 | No comment provided by engineer. 89 | 90 | 91 | Please make sure your internet conection is working properly 92 | Please make sure your internet conection is working properly 93 | No comment provided by engineer. 94 | 95 | 96 | Reload 97 | Reload 98 | No comment provided by engineer. 99 | 100 | 101 | Send 102 | Send 103 | No comment provided by engineer. 104 | 105 | 106 | Sorry, the page %1$@ was not found on %2$@%3$@ 107 | Sorry, the page %1$@ was not found on %2$@%3$@ 108 | Page not found error subtitle. first argument is the path, second the icon, third the host name 109 | 110 | 111 | Unknown Error 112 | Unknown Error 113 | No comment provided by engineer. 114 | 115 | 116 | example.org 117 | example.org 118 | No comment provided by engineer. 119 | 120 | 121 | 😔 The SSL certificate for %1$@%2$@ is invalid. 122 | 😔 The SSL certificate for %1$@%2$@ is invalid. 123 | SSL certificate invalid for this host. first argument is the emoji, the second the host name 124 | 125 | 126 |
127 |
128 | -------------------------------------------------------------------------------- /jimmy Localizations/en.xcloc/Source Contents/jimmy/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Bundle name */ 2 | "CFBundleName" = "Jimmy"; 3 | 4 | /* Copyright (human-readable) */ 5 | "NSHumanReadableCopyright" = "Copyright 2022 Jonathan Foucher"; 6 | 7 | -------------------------------------------------------------------------------- /jimmy Localizations/en.xcloc/Source Contents/jimmy/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy Localizations/en.xcloc/Source Contents/jimmy/en.lproj/Localizable.strings -------------------------------------------------------------------------------- /jimmy Localizations/en.xcloc/contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "developmentRegion" : "en", 3 | "project" : "jimmy.xcodeproj", 4 | "targetLocale" : "en", 5 | "toolInfo" : { 6 | "toolBuildNumber" : "13C100", 7 | "toolID" : "com.apple.dt.xcode", 8 | "toolName" : "Xcode", 9 | "toolVersion" : "13.2.1" 10 | }, 11 | "version" : "1.0" 12 | } -------------------------------------------------------------------------------- /jimmy Localizations/es.xcloc/Localized Contents/es.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 |
7 | 8 | 9 | Jimmy 10 | Jimmy 11 | Bundle name 12 | 13 | 14 | Copyright 2022 Jonathan Foucher 15 | Copyright 2022 Jonathan Foucher 16 | Copyright (human-readable) 17 | 18 | 19 |
20 | 21 |
22 | 23 |
24 | 25 | 26 | %d Page Not Found 27 | %d Pagina indisponible 28 | page not found title. First argument is the error code 29 | 30 | 31 | %d Server Error 32 | Error del servidor %d 33 | Generic server error title. First param is the error code 34 | 35 | 36 | Answer 37 | Respuesta 38 | No comment provided by engineer. 39 | 40 | 41 | Bookmarks 42 | Favoritos 43 | No comment provided by engineer. 44 | 45 | 46 | Clear history 47 | Vaciar historico 48 | No comment provided by engineer. 49 | 50 | 51 | Copy link address 52 | Copiar el enlace 53 | No comment provided by engineer. 54 | 55 | 56 | Could not connect 57 | Conneccion impossible 58 | No comment provided by engineer. 59 | 60 | 61 | Could not load %@ 62 | No se pudo cargar %@ 63 | Generic server error subtitle. First param is full url 64 | 65 | 66 | Ignore certificate validation for %1$@%2$@ 67 | Aceptar el certificado de %1$@%2$@ 68 | Button label to ignore certificate validation for this host 69 | 70 | 71 | Invalid certificate 72 | Certificado invalido 73 | No comment provided by engineer. 74 | 75 | 76 | New Tab 77 | Nueva pestaña 78 | No comment provided by engineer. 79 | 80 | 81 | Open Link in New Tab 82 | Abrir en nueva pestaña 83 | No comment provided by engineer. 84 | 85 | 86 | Open in new tab 87 | Abrir en nueva pestaña 88 | No comment provided by engineer. 89 | 90 | 91 | Please make sure your internet conection is working properly 92 | Asegurese de que su conección funcione correctamente 93 | No comment provided by engineer. 94 | 95 | 96 | Reload 97 | Recargar 98 | No comment provided by engineer. 99 | 100 | 101 | Send 102 | Enviar 103 | No comment provided by engineer. 104 | 105 | 106 | Sorry, the page %1$@ was not found on %2$@%3$@ 107 | Lo sentimos, la pagina %1$@ no se encontró en %2$@%3$@ 108 | Page not found error subtitle. first argument is the path, second the icon, third the host name 109 | 110 | 111 | Unknown Error 112 | Error desconocido 113 | No comment provided by engineer. 114 | 115 | 116 | example.org 117 | example.org 118 | No comment provided by engineer. 119 | 120 | 121 | 😔 The SSL certificate for %1$@%2$@ is invalid. 122 | 😔 El certificado SSL de %1$@%2$@ esta invalido 123 | SSL certificate invalid for this host. first argument is the emoji, the second the host name 124 | 125 | 126 |
127 |
128 | -------------------------------------------------------------------------------- /jimmy Localizations/es.xcloc/Source Contents/jimmy/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Bundle name */ 2 | "CFBundleName" = "Jimmy"; 3 | 4 | /* Copyright (human-readable) */ 5 | "NSHumanReadableCopyright" = "Copyright 2022 Jonathan Foucher"; 6 | 7 | -------------------------------------------------------------------------------- /jimmy Localizations/es.xcloc/Source Contents/jimmy/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy Localizations/es.xcloc/Source Contents/jimmy/en.lproj/Localizable.strings -------------------------------------------------------------------------------- /jimmy Localizations/es.xcloc/contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "developmentRegion" : "en", 3 | "project" : "jimmy.xcodeproj", 4 | "targetLocale" : "es", 5 | "toolInfo" : { 6 | "toolBuildNumber" : "13C100", 7 | "toolID" : "com.apple.dt.xcode", 8 | "toolName" : "Xcode", 9 | "toolVersion" : "13.2.1" 10 | }, 11 | "version" : "1.0" 12 | } -------------------------------------------------------------------------------- /jimmy Localizations/fr.xcloc/Localized Contents/fr.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 |
7 | 8 | 9 | Jimmy 10 | Bundle name 11 | 12 | 13 | Copyright 2022 Jonathan Foucher 14 | Copyright (human-readable) 15 | 16 | 17 |
18 | 19 |
20 | 21 |
22 | 23 | 24 | %d Page Not Found 25 | %d Page Introuvable 26 | page not found title. First argument is the error code 27 | 28 | 29 | %d Server Error 30 | Erreur Serveur %d 31 | Generic server error title. First param is the error code 32 | 33 | 34 | Answer 35 | Réponse 36 | No comment provided by engineer. 37 | 38 | 39 | Bookmarks 40 | Favoris 41 | No comment provided by engineer. 42 | 43 | 44 | Clear history 45 | Vider l'historique 46 | No comment provided by engineer. 47 | 48 | 49 | Copy link address 50 | Copier le lien 51 | No comment provided by engineer. 52 | 53 | 54 | Could not connect 55 | Connexion impossible 56 | No comment provided by engineer. 57 | 58 | 59 | Could not load %@ 60 | Impossible d'ouvrir %@ 61 | Generic server error subtitle. First param is full url 62 | 63 | 64 | Ignore certificate validation for %1$@%2$@ 65 | Ignorer la validation du certificat pour %1$@%2$@ 66 | Button label to ignore certificate validation for this host 67 | 68 | 69 | Invalid certificate 70 | Certificat invalide 71 | No comment provided by engineer. 72 | 73 | 74 | New Tab 75 | Nouvel Onglet 76 | No comment provided by engineer. 77 | 78 | 79 | Open Link in New Tab 80 | Ouvrir dans un nouvel onglet 81 | No comment provided by engineer. 82 | 83 | 84 | Open in new tab 85 | Ouvrir dans un nouvel onglet 86 | No comment provided by engineer. 87 | 88 | 89 | Please make sure your internet conection is working properly 90 | Assurez-vous que votre connexion internet fonctionne 91 | No comment provided by engineer. 92 | 93 | 94 | Reload 95 | Recharger 96 | No comment provided by engineer. 97 | 98 | 99 | Send 100 | Envoyer 101 | No comment provided by engineer. 102 | 103 | 104 | Sorry, the page %1$@ was not found on %2$@%3$@ 105 | Désolé, la page %1$@ n'a pas été trouvée sur %2$@%3$@ 106 | Page not found error subtitle. first argument is the path, second the icon, third the host name 107 | 108 | 109 | Unknown Error 110 | Erreur inconnue 111 | No comment provided by engineer. 112 | 113 | 114 | example.org 115 | example.org 116 | No comment provided by engineer. 117 | 118 | 119 | 😔 The SSL certificate for %1$@%2$@ is invalid. 120 | 😔 Le certificat SSL de %1$@%2$@ est invalide 121 | SSL certificate invalid for this host. first argument is the emoji, the second the host name 122 | 123 | 124 |
125 |
126 | -------------------------------------------------------------------------------- /jimmy Localizations/fr.xcloc/Source Contents/jimmy/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Bundle name */ 2 | "CFBundleName" = "Jimmy"; 3 | 4 | /* Copyright (human-readable) */ 5 | "NSHumanReadableCopyright" = "Copyright 2022 Jonathan Foucher"; 6 | 7 | -------------------------------------------------------------------------------- /jimmy Localizations/fr.xcloc/Source Contents/jimmy/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy Localizations/fr.xcloc/Source Contents/jimmy/en.lproj/Localizable.strings -------------------------------------------------------------------------------- /jimmy Localizations/fr.xcloc/contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "developmentRegion" : "en", 3 | "project" : "jimmy.xcodeproj", 4 | "targetLocale" : "fr", 5 | "toolInfo" : { 6 | "toolBuildNumber" : "13C100", 7 | "toolID" : "com.apple.dt.xcode", 8 | "toolName" : "Xcode", 9 | "toolVersion" : "13.2.1" 10 | }, 11 | "version" : "1.0" 12 | } -------------------------------------------------------------------------------- /jimmy.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | AB042ABD27C15EA900FB9503 /* Bookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB042ABC27C15EA800FB9503 /* Bookmarks.swift */; }; 11 | AB042ABF27C160AD00FB9503 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB042ABE27C160AD00FB9503 /* BookmarksView.swift */; }; 12 | AB042AC127C1615700FB9503 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB042AC027C1615700FB9503 /* Bookmark.swift */; }; 13 | AB042AC327C1761800FB9503 /* BookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB042AC227C1761800FB9503 /* BookmarkView.swift */; }; 14 | AB15E4D427C81A6200B36174 /* SourceCodePro-VariableFont_wght.ttf in Resources */ = {isa = PBXBuildFile; fileRef = AB15E4D327C81A6200B36174 /* SourceCodePro-VariableFont_wght.ttf */; }; 15 | AB15E4FB27C82BFF00B36174 /* Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB15E4FA27C82BFF00B36174 /* Actions.swift */; }; 16 | AB15E4FD27C940D500B36174 /* AttributedTextImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB15E4FC27C940D500B36174 /* AttributedTextImpl.swift */; }; 17 | AB15E4FF27C941D600B36174 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB15E4FE27C941D600B36174 /* AttributedText.swift */; }; 18 | AB15E50127CC032900B36174 /* TabTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB15E50027CC032900B36174 /* TabTextView.swift */; }; 19 | AB15E50327CC038000B36174 /* TabLineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB15E50227CC038000B36174 /* TabLineView.swift */; }; 20 | AB251EB927CFC977001ED0DB /* HistoryItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB251EB827CFC977001ED0DB /* HistoryItemView.swift */; }; 21 | AB251EBB27CFCC2C001ED0DB /* HistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB251EBA27CFCC2C001ED0DB /* HistoryItem.swift */; }; 22 | AB251EBD27CFED2B001ED0DB /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB251EBC27CFED2B001ED0DB /* HistoryView.swift */; }; 23 | AB32639E27BE3D5D004F93E0 /* Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB32639D27BE3D5D004F93E0 /* Tab.swift */; }; 24 | AB3263A027BE64AA004F93E0 /* ContentParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB32639F27BE64AA004F93E0 /* ContentParser.swift */; }; 25 | AB3263A627BE7A5A004F93E0 /* LineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3263A527BE7A5A004F93E0 /* LineView.swift */; }; 26 | AB3263A827BE7D6E004F93E0 /* Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3263A727BE7D6E004F93E0 /* Header.swift */; }; 27 | AB3263AD27C02316004F93E0 /* TabContentWrapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3263AC27C02316004F93E0 /* TabContentWrapperView.swift */; }; 28 | AB3263B127C04946004F93E0 /* URLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3263B027C04946004F93E0 /* URLParser.swift */; }; 29 | AB47128027C7DD4000D21B94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB47127E27C7DD4000D21B94 /* InfoPlist.strings */; }; 30 | AB47128327C7DD6800D21B94 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB47128127C7DD6800D21B94 /* Localizable.strings */; }; 31 | ABA5CE9827CC30B200403CB2 /* String+IDNA.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA5CE9727CC30B200403CB2 /* String+IDNA.swift */; }; 32 | ABA5CE9E27CC31D700403CB2 /* Scanner+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA5CE9927CC31D700403CB2 /* Scanner+Extensions.swift */; }; 33 | ABA5CE9F27CC31D700403CB2 /* UTS46.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA5CE9A27CC31D700403CB2 /* UTS46.swift */; }; 34 | ABA5CEA027CC31D700403CB2 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA5CE9B27CC31D700403CB2 /* Data+Extensions.swift */; }; 35 | ABA5CEA127CC31D700403CB2 /* UTS46+Loading.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA5CE9C27CC31D700403CB2 /* UTS46+Loading.swift */; }; 36 | ABA5CEA227CC31D700403CB2 /* uts46 in Resources */ = {isa = PBXBuildFile; fileRef = ABA5CE9D27CC31D700403CB2 /* uts46 */; }; 37 | ABC46C6C27D2AC3A00459203 /* TabHistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC46C6B27D2AC3A00459203 /* TabHistoryItem.swift */; }; 38 | ABE3E17C27BDADBE00418606 /* jimmyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE3E17B27BDADBE00418606 /* jimmyApp.swift */; }; 39 | ABE3E17E27BDADBE00418606 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE3E17D27BDADBE00418606 /* ContentView.swift */; }; 40 | ABE3E18027BDADBF00418606 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ABE3E17F27BDADBF00418606 /* Assets.xcassets */; }; 41 | ABE3E18327BDADBF00418606 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ABE3E18227BDADBF00418606 /* Preview Assets.xcassets */; }; 42 | ABE3E18B27BDB1E000418606 /* ClientConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE3E18A27BDB1E000418606 /* ClientConnection.swift */; }; 43 | ABE3E18D27BDB29400418606 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE3E18C27BDB29400418606 /* Client.swift */; }; 44 | ABF369F127C6DCB70071A557 /* CommandsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF369F027C6DCB70071A557 /* CommandsView.swift */; }; 45 | ABF369F327C6E7350071A557 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF369F227C6E7350071A557 /* History.swift */; }; 46 | ABF369F527C6E7A00071A557 /* IgnoredCertificates.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF369F427C6E7A00071A557 /* IgnoredCertificates.swift */; }; 47 | ABF9747D27C2F4B300812AE4 /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF9747C27C2F4B300812AE4 /* Emojis.swift */; }; 48 | /* End PBXBuildFile section */ 49 | 50 | /* Begin PBXFileReference section */ 51 | AB042ABC27C15EA800FB9503 /* Bookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmarks.swift; sourceTree = ""; }; 52 | AB042ABE27C160AD00FB9503 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; 53 | AB042AC027C1615700FB9503 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; 54 | AB042AC227C1761800FB9503 /* BookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkView.swift; sourceTree = ""; }; 55 | AB15E4D227C7DE4800B36174 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 56 | AB15E4D327C81A6200B36174 /* SourceCodePro-VariableFont_wght.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SourceCodePro-VariableFont_wght.ttf"; sourceTree = ""; }; 57 | AB15E4FA27C82BFF00B36174 /* Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Actions.swift; sourceTree = ""; }; 58 | AB15E4FC27C940D500B36174 /* AttributedTextImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedTextImpl.swift; sourceTree = ""; }; 59 | AB15E4FE27C941D600B36174 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = ""; }; 60 | AB15E50027CC032900B36174 /* TabTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabTextView.swift; sourceTree = ""; }; 61 | AB15E50227CC038000B36174 /* TabLineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabLineView.swift; sourceTree = ""; }; 62 | AB251EB827CFC977001ED0DB /* HistoryItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemView.swift; sourceTree = ""; }; 63 | AB251EBA27CFCC2C001ED0DB /* HistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItem.swift; sourceTree = ""; }; 64 | AB251EBC27CFED2B001ED0DB /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 65 | AB32639D27BE3D5D004F93E0 /* Tab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tab.swift; sourceTree = ""; }; 66 | AB32639F27BE64AA004F93E0 /* ContentParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentParser.swift; sourceTree = ""; }; 67 | AB3263A527BE7A5A004F93E0 /* LineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineView.swift; sourceTree = ""; }; 68 | AB3263A727BE7D6E004F93E0 /* Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Header.swift; sourceTree = ""; }; 69 | AB3263AB27BF0D32004F93E0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 70 | AB3263AC27C02316004F93E0 /* TabContentWrapperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabContentWrapperView.swift; sourceTree = ""; }; 71 | AB3263B027C04946004F93E0 /* URLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLParser.swift; sourceTree = ""; }; 72 | AB3263B227C0588A004F93E0 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 73 | AB3263B327C05A3F004F93E0 /* logo.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = logo.svg; sourceTree = ""; }; 74 | AB47127F27C7DD4000D21B94 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; 75 | AB47128227C7DD6800D21B94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 76 | AB47128427C7DDD000D21B94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; 77 | ABA5CE9727CC30B200403CB2 /* String+IDNA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+IDNA.swift"; sourceTree = ""; }; 78 | ABA5CE9927CC31D700403CB2 /* Scanner+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Scanner+Extensions.swift"; sourceTree = ""; }; 79 | ABA5CE9A27CC31D700403CB2 /* UTS46.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UTS46.swift; sourceTree = ""; }; 80 | ABA5CE9B27CC31D700403CB2 /* Data+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; 81 | ABA5CE9C27CC31D700403CB2 /* UTS46+Loading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UTS46+Loading.swift"; sourceTree = ""; }; 82 | ABA5CE9D27CC31D700403CB2 /* uts46 */ = {isa = PBXFileReference; lastKnownFileType = file; path = uts46; sourceTree = ""; }; 83 | ABC46C6B27D2AC3A00459203 /* TabHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabHistoryItem.swift; sourceTree = ""; }; 84 | ABE3E17827BDADBE00418606 /* jimmy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = jimmy.app; sourceTree = BUILT_PRODUCTS_DIR; }; 85 | ABE3E17B27BDADBE00418606 /* jimmyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = jimmyApp.swift; sourceTree = ""; }; 86 | ABE3E17D27BDADBE00418606 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 87 | ABE3E17F27BDADBF00418606 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 88 | ABE3E18227BDADBF00418606 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 89 | ABE3E18427BDADBF00418606 /* jimmy.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = jimmy.entitlements; sourceTree = ""; }; 90 | ABE3E18A27BDB1E000418606 /* ClientConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientConnection.swift; sourceTree = ""; }; 91 | ABE3E18C27BDB29400418606 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; 92 | ABF369F027C6DCB70071A557 /* CommandsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsView.swift; sourceTree = ""; }; 93 | ABF369F227C6E7350071A557 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; 94 | ABF369F427C6E7A00071A557 /* IgnoredCertificates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IgnoredCertificates.swift; sourceTree = ""; }; 95 | ABF9747C27C2F4B300812AE4 /* Emojis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emojis.swift; sourceTree = ""; }; 96 | /* End PBXFileReference section */ 97 | 98 | /* Begin PBXFrameworksBuildPhase section */ 99 | ABE3E17527BDADBE00418606 /* Frameworks */ = { 100 | isa = PBXFrameworksBuildPhase; 101 | buildActionMask = 2147483647; 102 | files = ( 103 | ); 104 | runOnlyForDeploymentPostprocessing = 0; 105 | }; 106 | /* End PBXFrameworksBuildPhase section */ 107 | 108 | /* Begin PBXGroup section */ 109 | AB15E4D527C81A6D00B36174 /* Fonts */ = { 110 | isa = PBXGroup; 111 | children = ( 112 | AB15E4D327C81A6200B36174 /* SourceCodePro-VariableFont_wght.ttf */, 113 | ); 114 | path = Fonts; 115 | sourceTree = ""; 116 | }; 117 | ABA46DE227C3D23500C3DC41 /* Models */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | ABF9747C27C2F4B300812AE4 /* Emojis.swift */, 121 | AB3263A727BE7D6E004F93E0 /* Header.swift */, 122 | AB042ABC27C15EA800FB9503 /* Bookmarks.swift */, 123 | ABF369F227C6E7350071A557 /* History.swift */, 124 | ABF369F427C6E7A00071A557 /* IgnoredCertificates.swift */, 125 | AB042AC027C1615700FB9503 /* Bookmark.swift */, 126 | AB32639D27BE3D5D004F93E0 /* Tab.swift */, 127 | AB15E4FA27C82BFF00B36174 /* Actions.swift */, 128 | AB251EBA27CFCC2C001ED0DB /* HistoryItem.swift */, 129 | ABC46C6B27D2AC3A00459203 /* TabHistoryItem.swift */, 130 | ); 131 | path = Models; 132 | sourceTree = ""; 133 | }; 134 | ABA46DE327C3D25400C3DC41 /* Views */ = { 135 | isa = PBXGroup; 136 | children = ( 137 | AB3263A527BE7A5A004F93E0 /* LineView.swift */, 138 | AB042ABE27C160AD00FB9503 /* BookmarksView.swift */, 139 | AB042AC227C1761800FB9503 /* BookmarkView.swift */, 140 | ABE3E17D27BDADBE00418606 /* ContentView.swift */, 141 | AB251EB827CFC977001ED0DB /* HistoryItemView.swift */, 142 | AB3263AC27C02316004F93E0 /* TabContentWrapperView.swift */, 143 | AB15E50027CC032900B36174 /* TabTextView.swift */, 144 | AB15E50227CC038000B36174 /* TabLineView.swift */, 145 | ABF369F027C6DCB70071A557 /* CommandsView.swift */, 146 | AB15E4FC27C940D500B36174 /* AttributedTextImpl.swift */, 147 | AB15E4FE27C941D600B36174 /* AttributedText.swift */, 148 | AB251EBC27CFED2B001ED0DB /* HistoryView.swift */, 149 | ); 150 | path = Views; 151 | sourceTree = ""; 152 | }; 153 | ABA46DE427C3D29D00C3DC41 /* Network */ = { 154 | isa = PBXGroup; 155 | children = ( 156 | ABE3E18A27BDB1E000418606 /* ClientConnection.swift */, 157 | ABE3E18C27BDB29400418606 /* Client.swift */, 158 | ); 159 | path = Network; 160 | sourceTree = ""; 161 | }; 162 | ABA46DE527C3D2BA00C3DC41 /* Utils */ = { 163 | isa = PBXGroup; 164 | children = ( 165 | AB32639F27BE64AA004F93E0 /* ContentParser.swift */, 166 | ABA5CE9B27CC31D700403CB2 /* Data+Extensions.swift */, 167 | ABA5CE9927CC31D700403CB2 /* Scanner+Extensions.swift */, 168 | ABA5CE9D27CC31D700403CB2 /* uts46 */, 169 | ABA5CE9A27CC31D700403CB2 /* UTS46.swift */, 170 | ABA5CE9C27CC31D700403CB2 /* UTS46+Loading.swift */, 171 | AB3263B027C04946004F93E0 /* URLParser.swift */, 172 | ABA5CE9727CC30B200403CB2 /* String+IDNA.swift */, 173 | ); 174 | path = Utils; 175 | sourceTree = ""; 176 | }; 177 | ABE3E16F27BDADBE00418606 = { 178 | isa = PBXGroup; 179 | children = ( 180 | AB3263B327C05A3F004F93E0 /* logo.svg */, 181 | AB3263B227C0588A004F93E0 /* README.md */, 182 | ABE3E17A27BDADBE00418606 /* jimmy */, 183 | ABE3E17927BDADBE00418606 /* Products */, 184 | ); 185 | sourceTree = ""; 186 | }; 187 | ABE3E17927BDADBE00418606 /* Products */ = { 188 | isa = PBXGroup; 189 | children = ( 190 | ABE3E17827BDADBE00418606 /* jimmy.app */, 191 | ); 192 | name = Products; 193 | sourceTree = ""; 194 | }; 195 | ABE3E17A27BDADBE00418606 /* jimmy */ = { 196 | isa = PBXGroup; 197 | children = ( 198 | AB15E4D527C81A6D00B36174 /* Fonts */, 199 | ABA46DE527C3D2BA00C3DC41 /* Utils */, 200 | ABA46DE427C3D29D00C3DC41 /* Network */, 201 | ABA46DE327C3D25400C3DC41 /* Views */, 202 | ABA46DE227C3D23500C3DC41 /* Models */, 203 | AB3263AB27BF0D32004F93E0 /* Info.plist */, 204 | AB47128127C7DD6800D21B94 /* Localizable.strings */, 205 | AB47127E27C7DD4000D21B94 /* InfoPlist.strings */, 206 | ABE3E17B27BDADBE00418606 /* jimmyApp.swift */, 207 | ABE3E17F27BDADBF00418606 /* Assets.xcassets */, 208 | ABE3E18427BDADBF00418606 /* jimmy.entitlements */, 209 | ABE3E18127BDADBF00418606 /* Preview Content */, 210 | ); 211 | path = jimmy; 212 | sourceTree = ""; 213 | }; 214 | ABE3E18127BDADBF00418606 /* Preview Content */ = { 215 | isa = PBXGroup; 216 | children = ( 217 | ABE3E18227BDADBF00418606 /* Preview Assets.xcassets */, 218 | ); 219 | path = "Preview Content"; 220 | sourceTree = ""; 221 | }; 222 | /* End PBXGroup section */ 223 | 224 | /* Begin PBXNativeTarget section */ 225 | ABE3E17727BDADBE00418606 /* jimmy */ = { 226 | isa = PBXNativeTarget; 227 | buildConfigurationList = ABE3E18727BDADBF00418606 /* Build configuration list for PBXNativeTarget "jimmy" */; 228 | buildPhases = ( 229 | ABE3E17427BDADBE00418606 /* Sources */, 230 | ABE3E17527BDADBE00418606 /* Frameworks */, 231 | ABE3E17627BDADBE00418606 /* Resources */, 232 | ); 233 | buildRules = ( 234 | ); 235 | dependencies = ( 236 | ); 237 | name = jimmy; 238 | productName = jimmy; 239 | productReference = ABE3E17827BDADBE00418606 /* jimmy.app */; 240 | productType = "com.apple.product-type.application"; 241 | }; 242 | /* End PBXNativeTarget section */ 243 | 244 | /* Begin PBXProject section */ 245 | ABE3E17027BDADBE00418606 /* Project object */ = { 246 | isa = PBXProject; 247 | attributes = { 248 | BuildIndependentTargetsInParallel = 1; 249 | LastSwiftUpdateCheck = 1320; 250 | LastUpgradeCheck = 1420; 251 | TargetAttributes = { 252 | ABE3E17727BDADBE00418606 = { 253 | CreatedOnToolsVersion = 13.2.1; 254 | }; 255 | }; 256 | }; 257 | buildConfigurationList = ABE3E17327BDADBE00418606 /* Build configuration list for PBXProject "jimmy" */; 258 | compatibilityVersion = "Xcode 13.0"; 259 | developmentRegion = en; 260 | hasScannedForEncodings = 0; 261 | knownRegions = ( 262 | en, 263 | fr, 264 | es, 265 | Base, 266 | ); 267 | mainGroup = ABE3E16F27BDADBE00418606; 268 | productRefGroup = ABE3E17927BDADBE00418606 /* Products */; 269 | projectDirPath = ""; 270 | projectRoot = ""; 271 | targets = ( 272 | ABE3E17727BDADBE00418606 /* jimmy */, 273 | ); 274 | }; 275 | /* End PBXProject section */ 276 | 277 | /* Begin PBXResourcesBuildPhase section */ 278 | ABE3E17627BDADBE00418606 /* Resources */ = { 279 | isa = PBXResourcesBuildPhase; 280 | buildActionMask = 2147483647; 281 | files = ( 282 | ABE3E18327BDADBF00418606 /* Preview Assets.xcassets in Resources */, 283 | AB47128327C7DD6800D21B94 /* Localizable.strings in Resources */, 284 | AB47128027C7DD4000D21B94 /* InfoPlist.strings in Resources */, 285 | ABA5CEA227CC31D700403CB2 /* uts46 in Resources */, 286 | ABE3E18027BDADBF00418606 /* Assets.xcassets in Resources */, 287 | AB15E4D427C81A6200B36174 /* SourceCodePro-VariableFont_wght.ttf in Resources */, 288 | ); 289 | runOnlyForDeploymentPostprocessing = 0; 290 | }; 291 | /* End PBXResourcesBuildPhase section */ 292 | 293 | /* Begin PBXSourcesBuildPhase section */ 294 | ABE3E17427BDADBE00418606 /* Sources */ = { 295 | isa = PBXSourcesBuildPhase; 296 | buildActionMask = 2147483647; 297 | files = ( 298 | ABF9747D27C2F4B300812AE4 /* Emojis.swift in Sources */, 299 | AB3263B127C04946004F93E0 /* URLParser.swift in Sources */, 300 | ABF369F127C6DCB70071A557 /* CommandsView.swift in Sources */, 301 | ABE3E18B27BDB1E000418606 /* ClientConnection.swift in Sources */, 302 | ABA5CE9F27CC31D700403CB2 /* UTS46.swift in Sources */, 303 | ABE3E17E27BDADBE00418606 /* ContentView.swift in Sources */, 304 | AB15E4FB27C82BFF00B36174 /* Actions.swift in Sources */, 305 | ABC46C6C27D2AC3A00459203 /* TabHistoryItem.swift in Sources */, 306 | AB042ABD27C15EA900FB9503 /* Bookmarks.swift in Sources */, 307 | ABF369F527C6E7A00071A557 /* IgnoredCertificates.swift in Sources */, 308 | AB15E50127CC032900B36174 /* TabTextView.swift in Sources */, 309 | AB15E50327CC038000B36174 /* TabLineView.swift in Sources */, 310 | AB3263A627BE7A5A004F93E0 /* LineView.swift in Sources */, 311 | ABE3E18D27BDB29400418606 /* Client.swift in Sources */, 312 | ABF369F327C6E7350071A557 /* History.swift in Sources */, 313 | AB15E4FF27C941D600B36174 /* AttributedText.swift in Sources */, 314 | AB32639E27BE3D5D004F93E0 /* Tab.swift in Sources */, 315 | AB251EBD27CFED2B001ED0DB /* HistoryView.swift in Sources */, 316 | ABA5CEA027CC31D700403CB2 /* Data+Extensions.swift in Sources */, 317 | AB042ABF27C160AD00FB9503 /* BookmarksView.swift in Sources */, 318 | ABA5CE9827CC30B200403CB2 /* String+IDNA.swift in Sources */, 319 | AB3263A027BE64AA004F93E0 /* ContentParser.swift in Sources */, 320 | ABA5CE9E27CC31D700403CB2 /* Scanner+Extensions.swift in Sources */, 321 | AB3263A827BE7D6E004F93E0 /* Header.swift in Sources */, 322 | AB3263AD27C02316004F93E0 /* TabContentWrapperView.swift in Sources */, 323 | AB251EBB27CFCC2C001ED0DB /* HistoryItem.swift in Sources */, 324 | AB15E4FD27C940D500B36174 /* AttributedTextImpl.swift in Sources */, 325 | AB042AC327C1761800FB9503 /* BookmarkView.swift in Sources */, 326 | AB251EB927CFC977001ED0DB /* HistoryItemView.swift in Sources */, 327 | ABA5CEA127CC31D700403CB2 /* UTS46+Loading.swift in Sources */, 328 | ABE3E17C27BDADBE00418606 /* jimmyApp.swift in Sources */, 329 | AB042AC127C1615700FB9503 /* Bookmark.swift in Sources */, 330 | ); 331 | runOnlyForDeploymentPostprocessing = 0; 332 | }; 333 | /* End PBXSourcesBuildPhase section */ 334 | 335 | /* Begin PBXVariantGroup section */ 336 | AB47127E27C7DD4000D21B94 /* InfoPlist.strings */ = { 337 | isa = PBXVariantGroup; 338 | children = ( 339 | AB47127F27C7DD4000D21B94 /* en */, 340 | AB47128427C7DDD000D21B94 /* es */, 341 | ); 342 | name = InfoPlist.strings; 343 | sourceTree = ""; 344 | }; 345 | AB47128127C7DD6800D21B94 /* Localizable.strings */ = { 346 | isa = PBXVariantGroup; 347 | children = ( 348 | AB47128227C7DD6800D21B94 /* fr */, 349 | AB15E4D227C7DE4800B36174 /* es */, 350 | ); 351 | name = Localizable.strings; 352 | sourceTree = ""; 353 | }; 354 | /* End PBXVariantGroup section */ 355 | 356 | /* Begin XCBuildConfiguration section */ 357 | ABE3E18527BDADBF00418606 /* Debug */ = { 358 | isa = XCBuildConfiguration; 359 | buildSettings = { 360 | ALWAYS_SEARCH_USER_PATHS = NO; 361 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 362 | CLANG_ANALYZER_NONNULL = YES; 363 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 364 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 365 | CLANG_CXX_LIBRARY = "libc++"; 366 | CLANG_ENABLE_MODULES = YES; 367 | CLANG_ENABLE_OBJC_ARC = YES; 368 | CLANG_ENABLE_OBJC_WEAK = YES; 369 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 370 | CLANG_WARN_BOOL_CONVERSION = YES; 371 | CLANG_WARN_COMMA = YES; 372 | CLANG_WARN_CONSTANT_CONVERSION = YES; 373 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 374 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 375 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 376 | CLANG_WARN_EMPTY_BODY = YES; 377 | CLANG_WARN_ENUM_CONVERSION = YES; 378 | CLANG_WARN_INFINITE_RECURSION = YES; 379 | CLANG_WARN_INT_CONVERSION = YES; 380 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 381 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 382 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 383 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 384 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 385 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 386 | CLANG_WARN_STRICT_PROTOTYPES = YES; 387 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 388 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 389 | CLANG_WARN_UNREACHABLE_CODE = YES; 390 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 391 | COPY_PHASE_STRIP = NO; 392 | DEAD_CODE_STRIPPING = YES; 393 | DEBUG_INFORMATION_FORMAT = dwarf; 394 | ENABLE_STRICT_OBJC_MSGSEND = YES; 395 | ENABLE_TESTABILITY = YES; 396 | GCC_C_LANGUAGE_STANDARD = gnu11; 397 | GCC_DYNAMIC_NO_PIC = NO; 398 | GCC_NO_COMMON_BLOCKS = YES; 399 | GCC_OPTIMIZATION_LEVEL = 0; 400 | GCC_PREPROCESSOR_DEFINITIONS = ( 401 | "DEBUG=1", 402 | "$(inherited)", 403 | ); 404 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 405 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 406 | GCC_WARN_UNDECLARED_SELECTOR = YES; 407 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 408 | GCC_WARN_UNUSED_FUNCTION = YES; 409 | GCC_WARN_UNUSED_VARIABLE = YES; 410 | MACOSX_DEPLOYMENT_TARGET = 12.0; 411 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 412 | MTL_FAST_MATH = YES; 413 | ONLY_ACTIVE_ARCH = YES; 414 | SDKROOT = macosx; 415 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 416 | SWIFT_EMIT_LOC_STRINGS = YES; 417 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 418 | }; 419 | name = Debug; 420 | }; 421 | ABE3E18627BDADBF00418606 /* Release */ = { 422 | isa = XCBuildConfiguration; 423 | buildSettings = { 424 | ALWAYS_SEARCH_USER_PATHS = NO; 425 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 426 | CLANG_ANALYZER_NONNULL = YES; 427 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 428 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 429 | CLANG_CXX_LIBRARY = "libc++"; 430 | CLANG_ENABLE_MODULES = YES; 431 | CLANG_ENABLE_OBJC_ARC = YES; 432 | CLANG_ENABLE_OBJC_WEAK = YES; 433 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 434 | CLANG_WARN_BOOL_CONVERSION = YES; 435 | CLANG_WARN_COMMA = YES; 436 | CLANG_WARN_CONSTANT_CONVERSION = YES; 437 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 438 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 439 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 440 | CLANG_WARN_EMPTY_BODY = YES; 441 | CLANG_WARN_ENUM_CONVERSION = YES; 442 | CLANG_WARN_INFINITE_RECURSION = YES; 443 | CLANG_WARN_INT_CONVERSION = YES; 444 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 445 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 446 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 447 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 448 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 449 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 450 | CLANG_WARN_STRICT_PROTOTYPES = YES; 451 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 452 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 453 | CLANG_WARN_UNREACHABLE_CODE = YES; 454 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 455 | COPY_PHASE_STRIP = NO; 456 | DEAD_CODE_STRIPPING = YES; 457 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 458 | ENABLE_NS_ASSERTIONS = NO; 459 | ENABLE_STRICT_OBJC_MSGSEND = YES; 460 | GCC_C_LANGUAGE_STANDARD = gnu11; 461 | GCC_NO_COMMON_BLOCKS = YES; 462 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 463 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 464 | GCC_WARN_UNDECLARED_SELECTOR = YES; 465 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 466 | GCC_WARN_UNUSED_FUNCTION = YES; 467 | GCC_WARN_UNUSED_VARIABLE = YES; 468 | MACOSX_DEPLOYMENT_TARGET = 12.0; 469 | MTL_ENABLE_DEBUG_INFO = NO; 470 | MTL_FAST_MATH = YES; 471 | SDKROOT = macosx; 472 | SWIFT_COMPILATION_MODE = wholemodule; 473 | SWIFT_EMIT_LOC_STRINGS = YES; 474 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 475 | }; 476 | name = Release; 477 | }; 478 | ABE3E18827BDADBF00418606 /* Debug */ = { 479 | isa = XCBuildConfiguration; 480 | buildSettings = { 481 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 482 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 483 | CODE_SIGN_ENTITLEMENTS = jimmy/jimmy.entitlements; 484 | CODE_SIGN_IDENTITY = "Apple Development"; 485 | CODE_SIGN_STYLE = Automatic; 486 | COMBINE_HIDPI_IMAGES = YES; 487 | CURRENT_PROJECT_VERSION = 1; 488 | DEAD_CODE_STRIPPING = YES; 489 | DEVELOPMENT_ASSET_PATHS = "\"jimmy/Preview Content\""; 490 | DEVELOPMENT_TEAM = 24G23598DF; 491 | ENABLE_HARDENED_RUNTIME = YES; 492 | ENABLE_PREVIEWS = YES; 493 | GENERATE_INFOPLIST_FILE = YES; 494 | INFOPLIST_FILE = jimmy/Info.plist; 495 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 496 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 497 | LD_RUNPATH_SEARCH_PATHS = ( 498 | "$(inherited)", 499 | "@executable_path/../Frameworks", 500 | ); 501 | MACOSX_DEPLOYMENT_TARGET = 12.0; 502 | MARKETING_VERSION = 0.2.4; 503 | PRODUCT_BUNDLE_IDENTIFIER = eu.6px.jimmy; 504 | PRODUCT_NAME = "$(TARGET_NAME)"; 505 | SWIFT_EMIT_LOC_STRINGS = YES; 506 | SWIFT_VERSION = 5.0; 507 | }; 508 | name = Debug; 509 | }; 510 | ABE3E18927BDADBF00418606 /* Release */ = { 511 | isa = XCBuildConfiguration; 512 | buildSettings = { 513 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 514 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 | CODE_SIGN_ENTITLEMENTS = jimmy/jimmy.entitlements; 516 | CODE_SIGN_IDENTITY = "Apple Development"; 517 | CODE_SIGN_STYLE = Automatic; 518 | COMBINE_HIDPI_IMAGES = YES; 519 | CURRENT_PROJECT_VERSION = 1; 520 | DEAD_CODE_STRIPPING = YES; 521 | DEVELOPMENT_ASSET_PATHS = "\"jimmy/Preview Content\""; 522 | DEVELOPMENT_TEAM = 24G23598DF; 523 | ENABLE_HARDENED_RUNTIME = YES; 524 | ENABLE_PREVIEWS = YES; 525 | GENERATE_INFOPLIST_FILE = YES; 526 | INFOPLIST_FILE = jimmy/Info.plist; 527 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 528 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 529 | LD_RUNPATH_SEARCH_PATHS = ( 530 | "$(inherited)", 531 | "@executable_path/../Frameworks", 532 | ); 533 | MACOSX_DEPLOYMENT_TARGET = 12.0; 534 | MARKETING_VERSION = 0.2.4; 535 | PRODUCT_BUNDLE_IDENTIFIER = eu.6px.jimmy; 536 | PRODUCT_NAME = "$(TARGET_NAME)"; 537 | SWIFT_EMIT_LOC_STRINGS = YES; 538 | SWIFT_VERSION = 5.0; 539 | }; 540 | name = Release; 541 | }; 542 | /* End XCBuildConfiguration section */ 543 | 544 | /* Begin XCConfigurationList section */ 545 | ABE3E17327BDADBE00418606 /* Build configuration list for PBXProject "jimmy" */ = { 546 | isa = XCConfigurationList; 547 | buildConfigurations = ( 548 | ABE3E18527BDADBF00418606 /* Debug */, 549 | ABE3E18627BDADBF00418606 /* Release */, 550 | ); 551 | defaultConfigurationIsVisible = 0; 552 | defaultConfigurationName = Release; 553 | }; 554 | ABE3E18727BDADBF00418606 /* Build configuration list for PBXNativeTarget "jimmy" */ = { 555 | isa = XCConfigurationList; 556 | buildConfigurations = ( 557 | ABE3E18827BDADBF00418606 /* Debug */, 558 | ABE3E18927BDADBF00418606 /* Release */, 559 | ); 560 | defaultConfigurationIsVisible = 0; 561 | defaultConfigurationName = Release; 562 | }; 563 | /* End XCConfigurationList section */ 564 | }; 565 | rootObject = ABE3E17027BDADBE00418606 /* Project object */; 566 | } 567 | -------------------------------------------------------------------------------- /jimmy.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /jimmy.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /jimmy.xcodeproj/xcshareddata/xcschemes/jimmy-prod.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 49 | 51 | 57 | 58 | 59 | 60 | 66 | 68 | 74 | 75 | 76 | 77 | 79 | 80 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /jimmy.xcodeproj/xcshareddata/xcschemes/jimmy.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 68 | 70 | 76 | 77 | 78 | 79 | 81 | 82 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /jimmy.xcodeproj/xcuserdata/jonathan.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /jimmy.xcodeproj/xcuserdata/jonathan.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | jimmy-prod.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 1 11 | 12 | jimmy.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 0 16 | 17 | 18 | SuppressBuildableAutocreation 19 | 20 | ABE3E17727BDADBE00418606 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "systemTealColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/256-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/256-1.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/32-1.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/512-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/512-1.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "32.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "32-1.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "256.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "256-1.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "512-1.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.915", 26 | "blue" : "0.100", 27 | "green" : "0.100", 28 | "red" : "0.100" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/home.dataset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data" : [ 3 | { 4 | "filename" : "jimmy.gmi", 5 | "idiom" : "universal", 6 | "universal-type-identifier" : "dyn.ah62d4rv4ge80s5pm" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/home.dataset/jimmy.gmi: -------------------------------------------------------------------------------- 1 | # 🥳 Welcome to Jimmy 🎉 2 | 3 | 4 | ### Jimmy is a simple gemini browser for macOS 5 | 6 | Just type the URL you want to visit above, and press enter! 7 | 8 | Save your favorite sites as bookmarks to be able to reference them later. 9 | 10 | ## 🚀 A few links 11 | 12 | Here are some sites you can visit to start off: 13 | 14 | => gemini://medusae.space/ 15 | => gemini://transjovian.org/ 16 | => gemini://geminispace.info/ 17 | => gemini://gemini.6px.eu/ My personal capsule 18 | 19 | ### Any problems? 20 | => https://github.com/jfoucher/Jimmy/issues Open an issue on GitHub 21 | => mailto:jfoucher@6px.fr Contact me by email -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/home_es.dataset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data" : [ 3 | { 4 | "filename" : "jimmy.gmi", 5 | "idiom" : "universal", 6 | "universal-type-identifier" : "dyn.ah62d4rv4ge80s5pm" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/home_es.dataset/jimmy.gmi: -------------------------------------------------------------------------------- 1 | # 🥳 Bienvenido a Jimmy 🎉 2 | 3 | 4 | ### Jimmy es un navegador gemini para macOs 5 | 6 | Simplemente entra una URL gemini en la barra de URL y haga clic en Enter! 7 | 8 | Guarda tus capsulas favoritas y Abrelas facilmente desde el menu favoritos. 9 | 10 | ## 🚀 Algunos enlaces 11 | 12 | Aqui estan algunos enlaces para empezar: 13 | 14 | => gemini://medusae.space/ 15 | => gemini://transjovian.org/ 16 | => gemini://geminispace.info/ 17 | => gemini://gemini.6px.eu/ Mi capsula personal 18 | 19 | ### Algún problema? 20 | => https://github.com/jfoucher/Jimmy/issues Abre una issue en GitHub 21 | => mailto:jfoucher@6px.fr Contactame por email -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/home_fr.dataset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data" : [ 3 | { 4 | "filename" : "jimmy.gmi", 5 | "idiom" : "universal", 6 | "universal-type-identifier" : "dyn.ah62d4rv4ge80s5pm" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/home_fr.dataset/jimmy.gmi: -------------------------------------------------------------------------------- 1 | # 🥳 Bienvenue sur Jimmy 🎉 2 | 3 | 4 | ### Jimmy est un navigateur gemini pour macOs 5 | 6 | Entrez simplement l'url dans la barre ci-dessus et appuyez sur Entrée 7 | 8 | Enregistrez vos capsules favorites et retrouver les facilement dans vos favoris 9 | 10 | ## 🚀 Quelques liens 11 | 12 | Voici quelques sites que vous pouvez visiter pour démarrer : 13 | 14 | => gemini://medusae.space/ 15 | => gemini://transjovian.org/ 16 | => gemini://geminispace.info/ 17 | => gemini://gemini.6px.eu/ Ma capsule personnelle 18 | 19 | ### Des problèmes ? 20 | => https://github.com/jfoucher/Jimmy/issues Ouvrez une issue sur GitHub 21 | => mailto:jfoucher@6px.fr Contactez-moi par email -------------------------------------------------------------------------------- /jimmy/Assets.xcassets/urlbackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.500", 8 | "blue" : "200", 9 | "green" : "200", 10 | "red" : "200" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.200", 26 | "blue" : "128", 27 | "green" : "128", 28 | "red" : "128" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /jimmy/Fonts/SourceCodePro-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Fonts/SourceCodePro-VariableFont_wght.ttf -------------------------------------------------------------------------------- /jimmy/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ATSApplicationFontsPath 6 | Fonts/ 7 | CFBundleURLTypes 8 | 9 | 10 | CFBundleTypeRole 11 | Editor 12 | CFBundleURLName 13 | eu.6px.jimmy 14 | CFBundleURLSchemes 15 | 16 | gemini 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /jimmy/Models/Actions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Actions.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 24/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | class Actions: ObservableObject { 11 | @Published var reload = 0 12 | } 13 | -------------------------------------------------------------------------------- /jimmy/Models/Bookmark.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bookmark.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 19/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Bookmark: Identifiable, Decodable, Encodable { 11 | var id: UUID 12 | 13 | var url: URL 14 | 15 | init (url: URL) { 16 | self.url = url 17 | self.id = UUID() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /jimmy/Models/Bookmarks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bookmarks.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 19/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | class Bookmarks: ObservableObject { 11 | @Published var items: [Bookmark] 12 | 13 | init() { 14 | if let data = UserDefaults.standard.data(forKey: "bookmarks") { 15 | if let decoded = try? JSONDecoder().decode([Bookmark].self, from: data) { 16 | items = decoded 17 | return 18 | } 19 | } 20 | 21 | items = [] 22 | } 23 | 24 | func save() { 25 | if let encoded = try? JSONEncoder().encode(items) { 26 | UserDefaults.standard.set(encoded, forKey: "bookmarks") 27 | } 28 | } 29 | 30 | func remove(bookmark: Bookmark) { 31 | self.items = self.items.filter({$0.id != bookmark.id}) 32 | self.save(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /jimmy/Models/Emojis.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Emojis.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 20/02/2022. 6 | // 7 | 8 | import Foundation 9 | import CryptoKit 10 | import SwiftUI 11 | 12 | struct Emoji: Codable { 13 | var host: String 14 | var time: String 15 | var emoji: String 16 | } 17 | 18 | 19 | class Emojis { 20 | var emojis: [Emoji] = [] 21 | var officalEmojis: [Emoji] = [] 22 | var requestInProgress: Bool = false 23 | 24 | private var codepoints = [ 25 | 0x1F300...0x1F5FF, // Misc Symbols and Pictographs 26 | 0x1F680...0x1F6FF, // Transport and Map 27 | 0x1F600...0x1F64F, // Emoticons 28 | 0x1F1E6...0x1F1FF, // Regional country flags 29 | 0x2600...0x26FF, // Misc symbols 9728 - 9983 30 | 0x2700...0x27BF, // Dingbats 31 | 0x1F900...0x1F9FF, // Supplemental Symbols and Pictographs 129280 - 129535 32 | 65024...65039, // Variation selector 33 | 9100...9300, // Misc items 34 | ] 35 | 36 | init() { 37 | if let data = UserDefaults.standard.data(forKey: "emojis"), let decoded = try? JSONDecoder().decode([Emoji].self, from: data) { 38 | emojis = decoded 39 | } else { 40 | emojis = [] 41 | } 42 | 43 | if let data = UserDefaults.standard.data(forKey: "officialemojis"), let decoded = try? JSONDecoder().decode([Emoji].self, from: data) { 44 | officalEmojis = decoded 45 | } else { 46 | officalEmojis = [] 47 | } 48 | } 49 | 50 | func save() { 51 | if let encoded = try? JSONEncoder().encode(emojis) { 52 | UserDefaults.standard.set(encoded, forKey: "emojis") 53 | } 54 | if let encoded = try? JSONEncoder().encode(officalEmojis) { 55 | UserDefaults.standard.set(encoded, forKey: "officialemojis") 56 | } 57 | } 58 | 59 | func emoji(_ host: String) -> String { 60 | if let emo = officalEmojis.first(where: {$0.host == host}) { 61 | if let d = emo.time.toDate() { 62 | let now = Date() 63 | 64 | let interval = now.timeIntervalSince(d) 65 | if interval > 3600.0 && requestInProgress == false { 66 | print("Updating cached emoji") 67 | 68 | self.requestEmoji(host) 69 | } 70 | } 71 | return emo.emoji 72 | } 73 | 74 | if let emo = emojis.first(where: {$0.host == host}) { 75 | if let d = emo.time.toDate() { 76 | let now = Date() 77 | 78 | let interval = now.timeIntervalSince(d) 79 | // Only cache generated emojis for 10 seconds 80 | if interval > 10.0 && requestInProgress == false { 81 | self.requestEmoji(host) 82 | } 83 | } 84 | 85 | return emo.emoji 86 | } 87 | 88 | // nothing found for this host, so generate it now. 89 | return generateEmoji(host) 90 | } 91 | 92 | func generateEmoji(_ host: String) -> String { 93 | let hashed = SHA256.hash(data: Data(host.utf8)) 94 | 95 | let m = hashed.map( { byte in 96 | return String(format: "%02x", byte) 97 | }).joined(separator: "") 98 | let hs = m[m.startIndex.. (Error?, Data?) -> Void { 137 | return { error, message in 138 | if let message = message { 139 | if let range = message.firstRange(of: Data("\r\n".utf8)) { 140 | let headerRange = message.startIndex.. String { 179 | let dateFormatter = DateFormatter() 180 | dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" 181 | let stringDate: String = dateFormatter.string(from: self) 182 | return stringDate 183 | } 184 | } 185 | 186 | extension String { 187 | func toDate() -> Date? { 188 | let dateFormatter = DateFormatter() 189 | dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" 190 | let d = dateFormatter.date(from: self) 191 | 192 | return d 193 | } 194 | } 195 | 196 | 197 | extension Character { 198 | private static let refUnicodeSize: CGFloat = 8 199 | private static let refUnicodePng = 200 | Character("\u{1f588}").png(ofSize: Character.refUnicodeSize) 201 | 202 | func unicodeAvailable() -> Bool { 203 | if let refUnicodePng = Character.refUnicodePng, 204 | let myPng = self.png(ofSize: Character.refUnicodeSize) { 205 | return refUnicodePng != myPng 206 | } 207 | return false 208 | } 209 | 210 | func png(ofSize fontSize: CGFloat) -> Data? { 211 | let str = String(self) 212 | let size = str.size(withAttributes: [.font: NSFont.systemFont(ofSize: 16.0)]) 213 | 214 | let img = NSImage(size: size, flipped: false, drawingHandler: { rect in 215 | str.draw(in: rect, withAttributes: [.font: NSFont.systemFont(ofSize: 16.0)]) 216 | return true 217 | }) 218 | 219 | if let png = img.tiffRepresentation { 220 | return png 221 | } 222 | 223 | return nil 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /jimmy/Models/Header.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Header.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 17/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | class Header { 12 | var code: Int 13 | var contentType: String 14 | 15 | init(line: String) { 16 | if line .isEmpty { 17 | self.code = 50 18 | self.contentType = "text/plain" 19 | } else { 20 | var p = line.replacingOccurrences(of: "\n", with: "").replacingOccurrences(of: "\r", with: "").split(separator: " ") 21 | self.code = Int(p.removeFirst()) ?? 50 22 | 23 | self.contentType = p.joined(separator: " ") 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /jimmy/Models/History.swift: -------------------------------------------------------------------------------- 1 | // 2 | // History.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 23/02/2022. 6 | // 7 | 8 | 9 | import Foundation 10 | 11 | class History: ObservableObject { 12 | @Published var items: [HistoryItem] 13 | 14 | init() { 15 | if let data = UserDefaults.standard.data(forKey: "history") { 16 | if let decoded = try? JSONDecoder().decode([HistoryItem].self, from: data) { 17 | items = decoded 18 | return 19 | } 20 | } 21 | 22 | items = [] 23 | } 24 | 25 | private func load() -> [HistoryItem] { 26 | if let data = UserDefaults.standard.data(forKey: "history") { 27 | if let decoded = try? JSONDecoder().decode([HistoryItem].self, from: data) { 28 | return decoded 29 | } 30 | } 31 | return [] 32 | } 33 | 34 | func save() { 35 | if let encoded = try? JSONEncoder().encode(items) { 36 | UserDefaults.standard.set(encoded, forKey: "history") 37 | } 38 | } 39 | 40 | func addItem(_ item: HistoryItem) { 41 | items = load() 42 | if self.items.contains(item) { 43 | let oldItem = self.items.first(where: {$0 == item})! 44 | oldItem.date = Date() 45 | oldItem.snippet = item.snippet 46 | self.items = self.items.map { i in 47 | return i == oldItem ? oldItem : i 48 | } 49 | } else { 50 | self.items.append(item) 51 | } 52 | 53 | self.save() 54 | } 55 | 56 | func remove(item: HistoryItem) { 57 | items = load() 58 | self.items = self.items.filter({$0 != item}) 59 | self.save(); 60 | } 61 | 62 | func clear() { 63 | self.items = [] 64 | self.save() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /jimmy/Models/HistoryItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryItem.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 02/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | class HistoryItem: Codable, Equatable, Hashable { 11 | var url: URL 12 | var date: Date 13 | var snippet: String 14 | 15 | init(url: URL, date: Date, snippet: String) { 16 | self.url = url 17 | self.date = date 18 | self.snippet = snippet 19 | } 20 | 21 | static func == (lhs: HistoryItem, rhs: HistoryItem) -> Bool { 22 | return lhs.url == rhs.url 23 | } 24 | 25 | func hash(into hasher: inout Hasher) { 26 | hasher.combine(url) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /jimmy/Models/IgnoredCertificates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IgnoredCertificates.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 23/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | class IgnoredCertificates: ObservableObject { 12 | @Published var items: [String] 13 | 14 | init() { 15 | if let data = UserDefaults.standard.data(forKey: "ignored-certs") { 16 | if let decoded = try? JSONDecoder().decode([String].self, from: data) { 17 | items = decoded 18 | return 19 | } 20 | } 21 | 22 | items = [] 23 | } 24 | 25 | func save() { 26 | if let encoded = try? JSONEncoder().encode(items) { 27 | UserDefaults.standard.set(encoded, forKey: "ignored-certs") 28 | } 29 | } 30 | 31 | func remove(item: String) { 32 | self.items = self.items.filter({$0 != item}) 33 | self.save(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /jimmy/Models/Tab.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tab.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 17/02/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import CryptoKit 11 | import Network 12 | 13 | class Tab: ObservableObject, Hashable, Identifiable { 14 | var certs: IgnoredCertificates 15 | @Published var url: URL 16 | @Published var content: [LineView] 17 | @Published var textContent: NSAttributedString 18 | @Published var id: UUID 19 | @Published var loading: Bool = false 20 | @Published var history: [TabHistoryItem] 21 | @Published var status = "" 22 | @Published var icon = "" 23 | @Published var ignoredCertValidation = false 24 | @Published var fontSize = 16.0 25 | @Published var scrollPos: Double = 0.0 26 | 27 | var emojis = Emojis() 28 | private var globalHistory: History 29 | 30 | 31 | private var client: Client 32 | private var ranges: [Range]? 33 | private var selectedRangeIndex = 0 34 | 35 | 36 | init(url: URL) { 37 | self.url = url 38 | self.content = [] 39 | self.id = UUID() 40 | self.history = [] 41 | self.globalHistory = History() 42 | self.client = Client(host: "localhost", port: 1965, validateCert: true) 43 | self.certs = IgnoredCertificates() 44 | self.textContent = NSAttributedString(string: "") 45 | } 46 | 47 | func setHistory(_ histo: History) { 48 | globalHistory = histo 49 | } 50 | 51 | static func == (lhs: Tab, rhs: Tab) -> Bool { 52 | return lhs.id == rhs.id 53 | } 54 | 55 | func hash(into hasher: inout Hasher) { 56 | hasher.combine(id) 57 | } 58 | 59 | func stop() { 60 | self.client.stop() 61 | self.loading = false; 62 | self.status = "" 63 | } 64 | 65 | 66 | 67 | func load() { 68 | if history.count > 1 { 69 | var last = history.removeLast() 70 | last.scrollposition = self.scrollPos 71 | if (last.url != self.url) { 72 | history.append(last) 73 | } 74 | } 75 | 76 | self.client.stop() 77 | selectedRangeIndex = 0 78 | self.ranges = [] 79 | guard let host = self.url.host else { 80 | return 81 | } 82 | 83 | 84 | 85 | self.icon = emojis.emoji(host) 86 | 87 | if (host == "about") { 88 | let pre = Locale.preferredLanguages[0].prefix(2) 89 | let filename = "home_"+pre 90 | if let asset = NSDataAsset(name: filename) { 91 | let data = asset.data 92 | if let text = String(bytes: data, encoding: .utf8) { 93 | cb(error: nil, message: Data(("20 text/gemini\r\n" + text).utf8)) 94 | return 95 | } 96 | } else if let asset = NSDataAsset(name: "home") { 97 | let data = asset.data 98 | if let text = String(bytes: data, encoding: .utf8) { 99 | cb(error: nil, message: Data(("20 text/gemini\r\n" + text).utf8)) 100 | return 101 | } 102 | } 103 | } 104 | 105 | 106 | DispatchQueue.main.async { 107 | self.loading = true 108 | self.status = "Loading " + self.url.absoluteString.replacingOccurrences(of: "gemini://", with: "") 109 | self.ignoredCertValidation = self.certs.items.contains(self.url.host ?? "") 110 | } 111 | 112 | self.client = Client(host: host, port: 1965, validateCert: !certs.items.contains(url.host ?? "")) 113 | self.client.start() 114 | self.client.dataReceivedCallback = cb(error:message:) 115 | 116 | self.client.send(data: (url.absoluteString + "\r\n").data(using: .utf8)!) 117 | } 118 | 119 | func back() { 120 | self.client.stop() 121 | if self.history.count > 1 { 122 | self.history.removeLast() 123 | let item = self.history.removeLast() 124 | self.url = item.url; 125 | print("scroll pos was", item.scrollposition) 126 | cb(error: item.error, message: item.message) 127 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { 128 | self.scrollPos = item.scrollposition * 1.3 129 | } 130 | } 131 | } 132 | 133 | func cb(error: NWError?, message: Data?) { 134 | DispatchQueue.main.async { 135 | self.loading = false 136 | self.status = "" 137 | self.content = [] 138 | self.textContent = NSAttributedString(string: "") 139 | } 140 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { 141 | 142 | if let error = error { 143 | 144 | self.history.append(TabHistoryItem(url: self.url, scrollposition: 0.0, error: error, message: message)) 145 | let historyItem = HistoryItem(url: self.url, date: Date(), snippet: "Error") 146 | self.globalHistory.addItem(historyItem) 147 | 148 | 149 | let contentParser = ContentParser(content: Data([]), tab: self) 150 | if error == NWError.tls(-9808) || error == NWError.tls(-9813) { 151 | 152 | let ats = NSMutableAttributedString(string: String(localized: "Invalid certificate"), attributes: contentParser.title1Style) 153 | 154 | let format = NSLocalizedString("😔 The SSL certificate for %@%@ is invalid.", comment:"SSL certificate invalid for this host. first argument is the emoji, the second the host name") 155 | 156 | let ats2 = NSMutableAttributedString(string: String(format: format, self.emojis.emoji(self.url.host ?? ""), (self.url.host ?? "")), attributes: contentParser.title3Style) 157 | 158 | self.content = [ 159 | LineView(attributed: ats, tab: self), 160 | LineView(attributed: ats2, tab: self), 161 | LineView(data: Data("".utf8), type: "text/ignore-cert", tab: self) 162 | ] 163 | 164 | } else if error == NWError.tls(-9814) { 165 | 166 | let ats = NSMutableAttributedString(string: String(localized: "Expired certificate"), attributes: contentParser.title1Style) 167 | 168 | let format = NSLocalizedString("😔 The SSL certificate for %@%@ has expired.", comment:"SSL certificate expired for this host. first argument is the emoji, the second the host name") 169 | 170 | let ats2 = NSMutableAttributedString(string: String(format: format, self.emojis.emoji(self.url.host ?? ""), (self.url.host ?? "")), attributes: contentParser.title3Style) 171 | 172 | self.content = [ 173 | LineView(attributed: ats, tab: self), 174 | LineView(attributed: ats2, tab: self), 175 | LineView(data: Data("".utf8), type: "text/ignore-cert", tab: self) 176 | ] 177 | 178 | } else if error == NWError.dns(-65554) || error == NWError.dns(0) { 179 | let ats = NSMutableAttributedString(string: String(localized: "Could not connect"), attributes: contentParser.title1Style) 180 | 181 | let ats2 = NSMutableAttributedString(string: String(localized: "Please make sure your internet conection is working properly"), attributes: contentParser.title3Style) 182 | self.content = [ 183 | LineView(attributed: ats, tab: self), 184 | LineView(attributed: ats2, tab: self), 185 | ] 186 | 187 | } else { 188 | let ats = NSMutableAttributedString(string: String(localized: "Unknown Error"), attributes: contentParser.title1Style) 189 | 190 | let ats2 = NSMutableAttributedString(string: error.localizedDescription, attributes: contentParser.title1Style) 191 | 192 | debugPrint(error) 193 | 194 | self.content = [ 195 | LineView(attributed: ats, tab: self), 196 | LineView(attributed: ats2, tab: self), 197 | ] 198 | } 199 | 200 | } else if let message = message { 201 | // Parse the request response 202 | let parsedMessage = ContentParser(content: message, tab: self) 203 | 204 | let historyItem = HistoryItem(url: self.url, date: Date(), snippet: String(parsedMessage.firstTitle)) 205 | if !(30...39).contains(parsedMessage.header.code) { 206 | self.history.append(TabHistoryItem(url: self.url, scrollposition: 0.0, error: error, message: message)) 207 | self.globalHistory.addItem(historyItem) 208 | } 209 | 210 | if (20...29).contains(parsedMessage.header.code) && !parsedMessage.header.contentType.starts(with: "text/") && !parsedMessage.header.contentType.starts(with: "image/") { 211 | // If we have a success response but not of a type we can handle, let ContentParser trigger the file save dialog 212 | // Add to history 213 | 214 | return 215 | } 216 | 217 | if (10...19).contains(parsedMessage.header.code) { 218 | // Input, show answer input box 219 | let ats = NSMutableAttributedString(string: parsedMessage.header.contentType, attributes: parsedMessage.title1Style) 220 | self.content = [ 221 | LineView(attributed: ats, tab: self), 222 | LineView(data: Data(), type: "text/answer", tab: self), 223 | ] 224 | } else if (20...29).contains(parsedMessage.header.code) { 225 | // Success, show parsed content 226 | self.textContent = parsedMessage.attrStr 227 | self.content = parsedMessage.parsed 228 | } else if (30...39).contains(parsedMessage.header.code) { 229 | // Redirect 230 | if let redirect = URL(string: parsedMessage.header.contentType) { 231 | self.url = redirect 232 | self.load() 233 | } 234 | } else if parsedMessage.header.code == 51 { 235 | let format = NSLocalizedString("%d Page Not Found", comment:"page not found title. First argument is the error code") 236 | 237 | let ats = NSMutableAttributedString(string: String(format: format, parsedMessage.header.code), attributes: parsedMessage.title1Style) 238 | 239 | let format2 = NSLocalizedString("Sorry, the page %@ was not found on %@%@", comment:"Page not found error subtitle. first argument is the path, second the icon, third the host name") 240 | 241 | let ats2 = NSMutableAttributedString(string: String(format: format2, self.url.path, self.emojis.emoji(self.url.host ?? ""), (self.url.host ?? "")), attributes: parsedMessage.title3Style) 242 | 243 | self.content = [ 244 | LineView(attributed: ats, tab: self), 245 | LineView(attributed: ats2, tab: self), 246 | ] 247 | } else { 248 | 249 | let format1 = NSLocalizedString("%d Server Error", comment:"Generic server error title. First param is the error code") 250 | 251 | let ats = NSMutableAttributedString(string: String(format: format1, parsedMessage.header.code), attributes: parsedMessage.title1Style) 252 | 253 | let format = NSLocalizedString("Could not load %@", comment:"Generic server error subtitle. First param is full url") 254 | 255 | let ats2 = NSMutableAttributedString(string: String(format: format, self.url.absoluteString), attributes: parsedMessage.title2Style) 256 | 257 | ats2.append(NSAttributedString(string: "\n" + parsedMessage.header.contentType, attributes: parsedMessage.title3Style)) 258 | 259 | self.content = [ 260 | LineView(attributed: ats, tab: self), 261 | LineView(attributed: ats2, tab: self), 262 | ] 263 | } 264 | } 265 | } 266 | } 267 | 268 | func search(_ str: String) -> [Range] { 269 | let wholeRange = NSRange(self.textContent.string.startIndex..., in: self.textContent.string) 270 | let content = NSMutableAttributedString("") 271 | content.append(self.textContent) 272 | content.removeAttribute(.backgroundColor, range: wholeRange) 273 | 274 | if content.string.contains(str) { 275 | self.ranges = content.string.ranges(of: str, options: []) 276 | 277 | for range in ranges! { 278 | content.addAttribute(.backgroundColor, value: NSColor.systemGray.blended(withFraction: 0.5, of: NSColor.textBackgroundColor) ?? NSColor.gray, range: range.nsRange(in: content.string)) 279 | } 280 | 281 | self.textContent = content 282 | return ranges! 283 | } else { 284 | self.ranges = [] 285 | } 286 | 287 | self.textContent = content 288 | 289 | return [] 290 | } 291 | 292 | func enterSearch() { 293 | guard let ranges = self.ranges else { return } 294 | if ranges.count == 0 { 295 | return 296 | } 297 | let content = NSMutableAttributedString("") 298 | content.append(self.textContent) 299 | for range in ranges { 300 | content.addAttribute(.backgroundColor, value: NSColor.systemGray.blended(withFraction: 0.5, of: NSColor.textBackgroundColor) ?? NSColor.gray, range: range.nsRange(in: content.string)) 301 | } 302 | 303 | if selectedRangeIndex >= ranges.count { 304 | selectedRangeIndex = 0 305 | } 306 | 307 | let range = ranges[selectedRangeIndex] 308 | 309 | content.addAttribute(.backgroundColor, value: NSColor.green, range: range.nsRange(in: content.string)) 310 | 311 | selectedRangeIndex += 1 312 | 313 | 314 | self.textContent = content 315 | } 316 | } 317 | 318 | extension RangeExpression where Bound == String.Index { 319 | func nsRange(in string: S) -> NSRange { .init(self, in: string) } 320 | } 321 | 322 | extension String { 323 | func ranges(of substring: String, options: CompareOptions = [], locale: Locale? = nil) -> [Range] { 324 | var ranges: [Range] = [] 325 | while let range = range(of: substring, options: options, range: (ranges.last?.upperBound ?? self.startIndex).. Void)? = nil 18 | 19 | init(host: String, port: UInt16, validateCert: Bool) { 20 | self.host = NWEndpoint.Host(host) 21 | self.port = NWEndpoint.Port(rawValue: port)! 22 | 23 | let tlsopts = NWProtocolTLS.Options() 24 | 25 | sec_protocol_options_set_peer_authentication_required(tlsopts.securityProtocolOptions, validateCert) 26 | 27 | let params = NWParameters(tls: tlsopts, tcp: NWProtocolTCP.Options()) 28 | 29 | let nwConnection = NWConnection(host: self.host, port: self.port, using: params) 30 | self.connection = ClientConnection(nwConnection: nwConnection) 31 | } 32 | 33 | 34 | func start() { 35 | print("Client started \(host) \(port)") 36 | connection.didStopCallback = didStopCallback(error:message:) 37 | connection.start() 38 | } 39 | 40 | func stop() { 41 | connection.stop() 42 | } 43 | 44 | func send(data: Data) { 45 | connection.send(data: data) 46 | } 47 | 48 | func didStopCallback(error: NWError?, message: Data?) { 49 | if let dataReceivedCallback = self.dataReceivedCallback { 50 | dataReceivedCallback(error, message) 51 | self.dataReceivedCallback = nil 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /jimmy/Network/ClientConnection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClientConnection.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 16/02/2022. 6 | // 7 | 8 | 9 | import Foundation 10 | import Network 11 | 12 | @available(macOS 10.14, *) 13 | class ClientConnection { 14 | 15 | let nwConnection: NWConnection 16 | let queue = DispatchQueue(label: "Client connection Q") 17 | 18 | var read: String 19 | 20 | var data: Data 21 | 22 | init(nwConnection: NWConnection) { 23 | self.nwConnection = nwConnection 24 | self.read = "" 25 | self.data = Data() 26 | } 27 | 28 | var didStopCallback: ((NWError?, Data?) -> Void)? = nil 29 | 30 | func start() { 31 | nwConnection.stateUpdateHandler = stateDidChange(to:) 32 | setupReceive() 33 | nwConnection.start(queue: queue) 34 | } 35 | 36 | private func stateDidChange(to state: NWConnection.State) { 37 | switch state { 38 | case .waiting(let error): 39 | connectionDidFail(error: error) 40 | case .ready: 41 | print("Client connection ready") 42 | case .failed(let error): 43 | connectionDidFail(error: error) 44 | default: 45 | break 46 | } 47 | } 48 | 49 | private func setupReceive() { 50 | nwConnection.receive(minimumIncompleteLength: 0, maximumLength: 65536) { (data, _, isComplete, error) in 51 | if let data = data, !data.isEmpty { 52 | self.data += data 53 | } 54 | 55 | if isComplete { 56 | self.connectionDidEnd() 57 | } else if let error = error { 58 | self.connectionDidFail(error: error) 59 | } else { 60 | self.setupReceive() 61 | } 62 | } 63 | } 64 | 65 | func send(data: Data) { 66 | nwConnection.send(content: data, completion: .contentProcessed( { error in 67 | if let error = error { 68 | self.connectionDidFail(error: error) 69 | return 70 | } 71 | })) 72 | } 73 | 74 | func stop() { 75 | stop(error: nil, message: nil) 76 | } 77 | 78 | private func connectionDidFail(error: NWError) { 79 | self.stop(error: error, message: nil) 80 | } 81 | 82 | private func connectionDidEnd() { 83 | self.stop(error: nil, message: self.data) 84 | } 85 | 86 | private func stop(error: NWError?, message: Data?) { 87 | self.nwConnection.stateUpdateHandler = nil 88 | self.nwConnection.cancel() 89 | if let didStopCallback = self.didStopCallback { 90 | didStopCallback(error, message) 91 | self.didStopCallback = nil 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /jimmy/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /jimmy/Utils/ContentParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentParser.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 17/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | import SwiftUI 11 | 12 | enum BlockType { 13 | case text 14 | case pre 15 | case list 16 | case link 17 | case title1 18 | case title2 19 | case title3 20 | case quote 21 | case end 22 | } 23 | 24 | 25 | class ContentParser { 26 | var parsed: [LineView] = [] 27 | var header: Header 28 | var attrStr: NSAttributedString 29 | let tab: Tab 30 | let fontManager: NSFontManager = NSFontManager.shared 31 | var firstTitle = "" 32 | 33 | init(content: Data, tab: Tab) { 34 | self.attrStr = NSAttributedString(string: "") 35 | self.tab = tab 36 | self.parsed = [] 37 | self.header = Header(line: "") 38 | self.firstTitle = "" 39 | 40 | if let range = content.firstRange(of: Data("\r\n".utf8)) { 41 | let headerRange = content.startIndex.. Void in 69 | if result == NSApplication.ModalResponse.OK { 70 | if let fileurl = mySave.url { 71 | print("file url is", fileurl) 72 | do { 73 | try contentData.write(to: fileurl) 74 | } catch { 75 | print("error writing") 76 | } 77 | } else { 78 | print("no file url") 79 | } 80 | } else { 81 | print ("cancel") 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | 90 | func parseGemText(_ content: String) -> NSAttributedString { 91 | let lines = content.split(separator: "\n") 92 | let result = NSMutableAttributedString(string: "") 93 | var str: String = "" 94 | var pre = false 95 | var firstTitle: String? = nil 96 | for (index, l) in lines.enumerated() { 97 | let line = l.trimmingCharacters(in: CharacterSet(charactersIn: "\u{FEFF}")) 98 | let blockType = getBlockType(String(line)) 99 | if blockType == .pre { 100 | pre = !pre 101 | 102 | if !pre { 103 | let attr = getAttributesForType(.pre, link: nil) 104 | let pstr = NSAttributedString(string: "\n" + str + "\n", attributes: attr) 105 | 106 | result.append(pstr) 107 | 108 | str = "" 109 | } 110 | continue 111 | } 112 | 113 | str += getLineForType(String(line), type: blockType) + "\n" 114 | if pre { 115 | continue 116 | } 117 | 118 | let nextBlockType: BlockType = index+1 < lines.count ? getBlockType(String(lines[index+1])) : .end 119 | 120 | 121 | if (blockType != nextBlockType) || blockType == .link || blockType == .title1 || blockType == .title2 || blockType == .title3 { 122 | // output previous block 123 | 124 | if blockType == .quote || blockType == .list { 125 | str = "\n" + str 126 | } 127 | 128 | if (blockType == .title1 || blockType == .title1 || blockType == .title3) && firstTitle == nil { 129 | firstTitle = str.replacingOccurrences(of: "#", with: "").trimmingCharacters(in: .whitespacesAndNewlines) 130 | self.firstTitle = firstTitle! 131 | } 132 | 133 | if blockType == .link { 134 | let linkAS = getLinkAS(str) 135 | result.append(linkAS) 136 | } else if blockType == .quote { 137 | let quoteAS = getQuoteAS(str) 138 | result.append(quoteAS) 139 | } else { 140 | let attr = getAttributesForType(blockType, link: nil) 141 | 142 | result.append(NSAttributedString(string: str.trimmingCharacters(in: .whitespaces), attributes: attr)) 143 | } 144 | 145 | //self.parsed.append(LineView(data: Data(str.utf8), type: self.header.contentType, tab: self.tab)) 146 | str = "" 147 | } 148 | } 149 | 150 | return result 151 | } 152 | 153 | func getBlockType(_ l: String) -> BlockType { 154 | let line = l.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "\u{FEFF}")) 155 | if line.starts(with: "###") { 156 | return .title3 157 | } else if line.starts(with: "##") { 158 | return .title2 159 | } else if line.starts(with: "#") { 160 | return .title1 161 | } else if line.starts(with: "=>") { 162 | return .link 163 | } else if line.starts(with: "*") { 164 | return .list 165 | } else if line.starts(with: ">") { 166 | return .quote 167 | } else if line.starts(with: "```") { 168 | return .pre 169 | } else { 170 | return .text 171 | } 172 | } 173 | 174 | func getLinkAS(_ str: String) -> NSAttributedString { 175 | // create our NSTextAttachment 176 | let image1Attachment = NSTextAttachment() 177 | var newStr = str.replacingOccurrences(of: "=>", with: "", options: [], range: .init(NSRange(location: 0, length: 2), in: str)).trimmingCharacters(in: .whitespaces) 178 | if newStr.starts(with: "//") { 179 | newStr = newStr.replacingOccurrences(of: "//", with: "gemini://") 180 | } 181 | 182 | let link = parseLink(newStr) 183 | 184 | var linkLabel = "" 185 | if let label = link.label { 186 | linkLabel = label + "\n" 187 | } else { 188 | linkLabel = link.original + "\n" 189 | } 190 | let url = link.link 191 | let attr = getAttributesForType(.link, link: url) 192 | if linkLabel.startsWithEmoji { 193 | return NSAttributedString(string: linkLabel, attributes: attr) 194 | } 195 | var imgName = "arrow.right" 196 | if let scheme = url.scheme { 197 | if scheme.starts(with: "http") { 198 | imgName = "network" 199 | } 200 | if scheme.starts(with: "mailto") { 201 | imgName = "mail" 202 | } 203 | } 204 | 205 | 206 | image1Attachment.image = NSImage(systemSymbolName: imgName, accessibilityDescription: "") 207 | 208 | // // wrap the attachment in its own attributed string so we can append it 209 | let image1String = NSMutableAttributedString(attachment: image1Attachment) 210 | 211 | image1String.append(NSAttributedString(string: " ")) 212 | 213 | image1String.addAttribute(.foregroundColor, value: NSColor.controlAccentColor.blended(withFraction: 0.3, of: NSColor.green) ?? NSColor.green, range: NSRange(location: 0, length: 2)) 214 | image1String.addAttribute(.font, value: NSFont.systemFont(ofSize: tab.fontSize * 1.4), range: NSRange(location: 0, length: 2)) 215 | image1String.addAttribute(.baselineOffset, value: -tab.fontSize * 0.1, range: NSRange(location: 0, length: 2)) 216 | image1String.addAttribute(.paragraphStyle, value: attr[.paragraphStyle] ?? [], range: NSRange(location: 0, length: 2)) 217 | 218 | image1String.append(NSAttributedString(string: linkLabel, attributes: attr)) 219 | 220 | // 221 | return image1String 222 | } 223 | 224 | 225 | func getQuoteAS(_ str: String) -> NSAttributedString { 226 | // create our NSTextAttachment 227 | let image1Attachment = NSTextAttachment() 228 | let newStr = str.replacingOccurrences(of: ">", with: "", options: [], range: .init(NSRange(location: 0, length: 2), in: str)).trimmingCharacters(in: .whitespacesAndNewlines) 229 | 230 | let attr = getAttributesForType(.quote, link: nil) 231 | 232 | let imgName = "quote.opening" 233 | 234 | image1Attachment.image = NSImage(systemSymbolName: imgName, accessibilityDescription: "") 235 | 236 | // // wrap the attachment in its own attributed string so we can append it 237 | let image1String = NSMutableAttributedString(attachment: image1Attachment) 238 | 239 | image1String.append(NSAttributedString(string: " ")) 240 | 241 | let range = NSRange(location: 0, length: image1String.string.count) 242 | 243 | image1String.addAttribute(.foregroundColor, value: NSColor.controlAccentColor.blended(withFraction: 0.3, of: NSColor.green) ?? NSColor.green, range: range) 244 | image1String.addAttribute(.font, value: NSFont.systemFont(ofSize: tab.fontSize * 1.4), range: range) 245 | image1String.addAttribute(.baselineOffset, value: -tab.fontSize * 0.1, range: range) 246 | image1String.addAttribute(.kern, value: tab.fontSize * 2, range: range) 247 | let pst: NSMutableParagraphStyle = attr[.paragraphStyle] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() 248 | 249 | pst.paragraphSpacingBefore = tab.fontSize 250 | 251 | image1String.addAttribute(.paragraphStyle, value: pst, range: range) 252 | 253 | image1String.append(NSAttributedString(string: newStr + "\n", attributes: attr)) 254 | 255 | 256 | return image1String 257 | } 258 | 259 | func getLineForType(_ str: String, type: BlockType) -> String { 260 | switch type { 261 | case .text, .pre: 262 | return str 263 | 264 | case .list: 265 | return str.replacingOccurrences(of: "*", with: "•", options: [], range: .init(NSRange(location: 0, length: 1), in: str)).trimmingCharacters(in: .whitespaces) 266 | 267 | case .link: 268 | return str 269 | 270 | case .title1: 271 | return str.replacingOccurrences(of: "#", with: "", options: [], range: .init(NSRange(location: 0, length: 1), in: str)).trimmingCharacters(in: .whitespaces) 272 | case .title2: 273 | return str.replacingOccurrences(of: "#", with: "", options: [], range: .init(NSRange(location: 0, length: 2), in: str)).trimmingCharacters(in: .whitespaces) 274 | case .title3: 275 | return str.replacingOccurrences(of: "#", with: "", options: [], range: .init(NSRange(location: 0, length: 3), in: str)).trimmingCharacters(in: .whitespaces) 276 | case .quote: 277 | return str.replacingOccurrences(of: ">", with: "", options: [], range: .init(NSRange(location: 0, length: 1), in: str)).trimmingCharacters(in: .whitespaces) 278 | case .end: 279 | return str 280 | } 281 | } 282 | 283 | var title1Style: [NSAttributedString.Key: Any] { 284 | let pst = NSMutableParagraphStyle() 285 | pst.alignment = .center 286 | pst.lineSpacing = 0 287 | pst.paragraphSpacing = tab.fontSize 288 | pst.paragraphSpacingBefore = tab.fontSize * 3 289 | let italic: NSFont = fontManager.font(withFamily: ".AppleSystemUIFontSerif", traits: NSFontTraitMask.unitalicFontMask, weight: 400, size: tab.fontSize * 2) ?? NSFont.systemFont(ofSize: tab.fontSize * 2, weight: .heavy) 290 | 291 | return [ 292 | .font: italic, 293 | .paragraphStyle: pst, 294 | .foregroundColor: NSColor.textColor 295 | ] 296 | } 297 | 298 | var title2Style: [NSAttributedString.Key: Any] { 299 | let italic: NSFont = fontManager.font(withFamily: ".AppleSystemUIFontSerif", traits: NSFontTraitMask.italicFontMask, weight: 0, size: tab.fontSize * 1.5) ?? NSFont.systemFont(ofSize: tab.fontSize * 1.5, weight: .thin) 300 | let pst = NSMutableParagraphStyle() 301 | pst.alignment = .left 302 | pst.paragraphSpacing = 0 303 | pst.paragraphSpacingBefore = tab.fontSize * 1.2 304 | 305 | return [ 306 | .font: italic, 307 | .paragraphStyle: pst, 308 | .foregroundColor: NSColor.textColor 309 | ] 310 | } 311 | 312 | var title3Style: [NSAttributedString.Key: Any] { 313 | let italic: NSFont = fontManager.font(withFamily: ".AppleSystemUIFont", traits: NSFontTraitMask.unitalicFontMask, weight: 0, size: tab.fontSize * 1.3) ?? NSFont.systemFont(ofSize: tab.fontSize * 1.3, weight: .thin) 314 | let pst = NSMutableParagraphStyle() 315 | pst.alignment = .left 316 | pst.paragraphSpacing = 0 317 | pst.paragraphSpacingBefore = tab.fontSize * 1.2 318 | 319 | return [ 320 | .font: italic, 321 | .paragraphStyle: pst, 322 | .foregroundColor: NSColor.textColor 323 | ] 324 | } 325 | 326 | func getAttributesForType(_ type: BlockType, link: URL?) -> [NSAttributedString.Key: Any] { 327 | 328 | 329 | switch type { 330 | case .text: 331 | let pst = NSMutableParagraphStyle() 332 | pst.alignment = .left 333 | pst.paragraphSpacing = tab.fontSize 334 | pst.lineSpacing = tab.fontSize / 3 335 | pst.paragraphSpacingBefore = tab.fontSize 336 | 337 | let font = NSFont.systemFont(ofSize: tab.fontSize, weight: .light) 338 | return [ 339 | .font: font, 340 | .foregroundColor: NSColor.textColor, 341 | .paragraphStyle: pst 342 | ] 343 | case .pre: 344 | let pst = NSMutableParagraphStyle() 345 | pst.alignment = .left 346 | pst.paragraphSpacing = 0 347 | pst.paragraphSpacingBefore = 0 348 | let tabInterval : CGFloat = 75.0 349 | var tabs = [NSTextTab]() 350 | for i in 1...20 { tabs.append(NSTextTab(textAlignment: .left, location: tabInterval * CGFloat(i))) } 351 | pst.tabStops = tabs 352 | return [ 353 | .font: NSFont.monospacedSystemFont(ofSize: tab.fontSize, weight: NSFont.Weight.light), 354 | .foregroundColor: NSColor.textColor, 355 | .paragraphStyle: pst 356 | ] 357 | case .list: 358 | let pst = NSMutableParagraphStyle() 359 | pst.alignment = .left 360 | pst.lineSpacing = 0 361 | pst.headIndent = tab.fontSize * 3 362 | pst.firstLineHeadIndent = tab.fontSize * 3 363 | pst.lineSpacing = tab.fontSize / 2 364 | 365 | let font = NSFont.systemFont(ofSize: tab.fontSize) 366 | return [ 367 | .font: font, 368 | .foregroundColor: NSColor.textColor, 369 | .paragraphStyle: pst 370 | ] 371 | case .link: 372 | let pst = NSMutableParagraphStyle() 373 | pst.alignment = .left 374 | pst.lineSpacing = 0 375 | pst.paragraphSpacing = tab.fontSize / 2 376 | pst.paragraphSpacingBefore = tab.fontSize / 2 377 | let font = NSFont.systemFont(ofSize: tab.fontSize, weight: .bold) 378 | 379 | return [ 380 | .font: font, 381 | .link: link ?? URL(string: "gemini://about")!, 382 | .foregroundColor: NSColor.systemGray, 383 | .cursor: NSCursor.pointingHand, 384 | .paragraphStyle: pst 385 | ] 386 | case .title1: 387 | return title1Style 388 | case .title2: 389 | return title2Style 390 | case .title3: 391 | return title3Style 392 | case .quote: 393 | let pst = NSMutableParagraphStyle() 394 | pst.alignment = .left 395 | pst.lineSpacing = tab.fontSize / 3 396 | pst.headIndent = tab.fontSize * 4 397 | pst.firstLineHeadIndent = 0 //tab.fontSize * 2 398 | 399 | let font: NSFont = fontManager.font(withFamily: ".AppleSystemUIFontSerif", traits: NSFontTraitMask.italicFontMask, weight: 0, size: tab.fontSize * 1.3) ?? NSFont.systemFont(ofSize: tab.fontSize * 1.3, weight: .thin) 400 | return [ 401 | .font: font, 402 | .foregroundColor: NSColor.textColor, 403 | .paragraphStyle: pst 404 | ] 405 | case .end: 406 | return [:] 407 | } 408 | } 409 | 410 | private func parseLink(_ l: String) -> Link { 411 | let line = l.replacingOccurrences(of: "=>", with: "").trimmingCharacters(in: .whitespacesAndNewlines) 412 | let start = line.startIndex 413 | var end = line.endIndex 414 | if let endRange = line.range(of: "\t") { 415 | end = endRange.upperBound 416 | } else if let endRange = line.range(of: " ") { 417 | end = endRange.upperBound 418 | } 419 | 420 | 421 | 422 | let linkString = line[start.. 0x238C 455 | } 456 | 457 | /// Checks if the scalars will be merged into an emoji 458 | var isCombinedIntoEmoji: Bool { unicodeScalars.count > 1 && unicodeScalars.first?.properties.isEmoji ?? false } 459 | 460 | var isEmoji: Bool { isSimpleEmoji || isCombinedIntoEmoji } 461 | } 462 | 463 | extension String { 464 | var isSingleEmoji: Bool { count == 1 && containsEmoji } 465 | 466 | var containsEmoji: Bool { contains { $0.isEmoji } } 467 | 468 | var startsWithEmoji: Bool { 469 | if let f = self.first { 470 | return f.isEmoji 471 | } 472 | return false 473 | } 474 | 475 | var containsOnlyEmoji: Bool { !isEmpty && !contains { !$0.isEmoji } } 476 | 477 | var emojiString: String { emojis.map { String($0) }.reduce("", +) } 478 | 479 | var emojis: [Character] { filter { $0.isEmoji } } 480 | 481 | var emojiScalars: [UnicodeScalar] { filter { $0.isEmoji }.flatMap { $0.unicodeScalars } } 482 | } 483 | -------------------------------------------------------------------------------- /jimmy/Utils/Data+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data+Extensions.swift 3 | // PunyCocoa Swift 4 | // 5 | // Created by Nate Weaver on 2020-04-12. 6 | // 7 | 8 | import Foundation 9 | import zlib 10 | 11 | extension Data { 12 | 13 | var crc32: UInt32 { 14 | return self.withUnsafeBytes { 15 | let buffer = $0.bindMemory(to: UInt8.self) 16 | let initial = zlib.crc32(0, nil, 0) 17 | return UInt32(zlib.crc32(initial, buffer.baseAddress, numericCast(buffer.count))) 18 | } 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /jimmy/Utils/Scanner+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Scanner+Extensions.swift 3 | // PunyCocoa Swift 4 | // 5 | // Created by Nate Weaver on 2020-04-20. 6 | // 7 | 8 | import Foundation 9 | 10 | // Wrapper functions for < 10.15 compatibility. 11 | // TODO: Remove when support for < 10.15 is dropped. 12 | extension Scanner { 13 | 14 | func shimScanUpToCharacters(from set: CharacterSet) -> String? { 15 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) { 16 | return self.scanUpToCharacters(from: set) 17 | } else { 18 | var str: NSString? 19 | self.scanUpToCharacters(from: set, into: &str) 20 | return str as String? 21 | } 22 | } 23 | 24 | func shimScanCharacters(from set: CharacterSet) -> String? { 25 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) { 26 | return self.scanCharacters(from: set) 27 | } else { 28 | var str: NSString? 29 | self.scanCharacters(from: set, into: &str) 30 | return str as String? 31 | } 32 | } 33 | 34 | func shimScanUpToString(_ substring: String) -> String? { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) { 36 | return self.scanUpToString(substring) 37 | } else { 38 | var str: NSString? 39 | self.scanUpTo(substring, into: &str) 40 | return str as String? 41 | } 42 | } 43 | 44 | func shimScanString(_ searchString: String) -> String? { 45 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) { 46 | return self.scanString(searchString) 47 | } else { 48 | var str: NSString? 49 | self.scanString(searchString, into: &str) 50 | return str as String? 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /jimmy/Utils/String+IDNA.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+IDNA.swift 3 | // Punycode 4 | // 5 | // Created by Nate Weaver on 2020-03-16. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension String { 11 | 12 | /// The IDNA-encoded representation of a Unicode domain. 13 | /// 14 | /// This will properly split domains on periods; e.g., 15 | /// "www.bücher.ch" becomes "www.xn--bcher-kva.ch". 16 | var idnaEncoded: String? { 17 | guard let mapped = try? self.mapUTS46() else { return nil } 18 | 19 | let nonASCII = CharacterSet(charactersIn: UnicodeScalar(0)...UnicodeScalar(127)).inverted 20 | var result = "" 21 | 22 | let s = Scanner(string: mapped.precomposedStringWithCanonicalMapping) 23 | let dotAt = CharacterSet(charactersIn: ".@") 24 | 25 | while !s.isAtEnd { 26 | if let input = s.shimScanUpToCharacters(from: dotAt) { 27 | if !input.isValidLabel { return nil } 28 | 29 | if input.rangeOfCharacter(from: nonASCII) != nil { 30 | result.append("xn--") 31 | 32 | if let encoded = input.punycodeEncoded { 33 | result.append(encoded) 34 | } 35 | } else { 36 | result.append(input) 37 | } 38 | } 39 | 40 | if let input = s.shimScanCharacters(from: dotAt) { 41 | result.append(input) 42 | } 43 | } 44 | 45 | return result 46 | } 47 | 48 | /// The Unicode representation of an IDNA-encoded domain. 49 | /// 50 | /// This will properly split domains on periods; e.g., 51 | /// "www.xn--bcher-kva.ch" becomes "www.bücher.ch". 52 | var idnaDecoded: String? { 53 | var result = "" 54 | let s = Scanner(string: self) 55 | let dotAt = CharacterSet(charactersIn: ".@") 56 | 57 | while !s.isAtEnd { 58 | if let input = s.shimScanUpToCharacters(from: dotAt) { 59 | if input.lowercased().hasPrefix("xn--") { 60 | let start = input.index(input.startIndex, offsetBy: 4) 61 | guard let substr = input[start...].punycodeDecoded else { return nil } 62 | guard substr.isValidLabel else { return nil } 63 | result.append(substr) 64 | } else { 65 | result.append(input) 66 | } 67 | } 68 | 69 | if let input = s.shimScanCharacters(from: dotAt) { 70 | result.append(input) 71 | } 72 | } 73 | 74 | return result 75 | } 76 | 77 | /// The IDNA- and percent-encoded representation of a URL string. 78 | var encodedURLString: String? { 79 | let urlParts = self.urlParts 80 | var pathAndQuery = urlParts.pathAndQuery 81 | 82 | var allowedCharacters = CharacterSet.urlPathAllowed 83 | allowedCharacters.insert(charactersIn: "%?") 84 | pathAndQuery = pathAndQuery.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? "" 85 | 86 | var result = "\(urlParts.scheme)\(urlParts.delim)" 87 | 88 | if let username = urlParts.username?.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) { 89 | if let password = urlParts.password?.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) { 90 | result.append("\(username):\(password)@") 91 | } else { 92 | result.append("\(username)@") 93 | } 94 | } 95 | 96 | guard let host = urlParts.host.idnaEncoded else { return nil } 97 | 98 | result.append("\(host)\(pathAndQuery)") 99 | 100 | if var fragment = urlParts.fragment { 101 | var fragmentAlloweCharacters = CharacterSet.urlFragmentAllowed 102 | fragmentAlloweCharacters.insert(charactersIn: "%") 103 | fragment = fragment.addingPercentEncoding(withAllowedCharacters: fragmentAlloweCharacters) ?? "" 104 | 105 | result.append("#\(fragment)") 106 | } 107 | 108 | return result 109 | } 110 | 111 | /// The Unicode representation of an IDNA- and percent-encoded URL string. 112 | var decodedURLString: String? { 113 | let urlParts = self.urlParts 114 | var usernamePassword = "" 115 | 116 | if let username = urlParts.username?.removingPercentEncoding { 117 | if let password = urlParts.password?.removingPercentEncoding { 118 | usernamePassword = "\(username):\(password)@" 119 | } else { 120 | usernamePassword = "\(username)@" 121 | } 122 | } 123 | 124 | guard let host = urlParts.host.idnaDecoded else { return nil } 125 | 126 | var result = "\(urlParts.scheme)\(urlParts.delim)\(usernamePassword)\(host)\(urlParts.pathAndQuery.removingPercentEncoding ?? "")" 127 | 128 | if let fragment = urlParts.fragment?.removingPercentEncoding { 129 | result.append("#\(fragment)") 130 | } 131 | 132 | return result 133 | } 134 | 135 | } 136 | 137 | public extension URL { 138 | 139 | /// Initializes a URL with a Unicode URL string. 140 | /// 141 | /// If `unicodeString` can be successfully encoded, equivalent to 142 | /// 143 | /// ``` 144 | /// URL(string: unicodeString.encodedURLString!) 145 | /// ``` 146 | /// 147 | /// - Parameter unicodeString: The unicode URL string with which to create a URL. 148 | init?(unicodeString: String) { 149 | if let url = URL(string: unicodeString) { 150 | self = url 151 | return 152 | } 153 | 154 | guard let encodedString = unicodeString.encodedURLString else { return nil } 155 | self.init(string: encodedString) 156 | } 157 | 158 | 159 | /// The IDNA- and percent-decoded representation of the URL. 160 | /// 161 | /// Equivalent to 162 | /// 163 | /// ``` 164 | /// self.absoluteString.decodedURLString 165 | /// ``` 166 | var decodedURLString: String? { 167 | return self.absoluteString.decodedURLString 168 | } 169 | 170 | /// Initializes a URL from a relative Unicode string and a base URL. 171 | /// - Parameters: 172 | /// - unicodeString: The URL string with which to initialize the NSURL object. `unicodeString` is interpreted relative to `baseURL`. 173 | /// - url: The base URL for the URL object 174 | init?(unicodeString: String, relativeTo url: URL?) { 175 | if let url = URL(string: unicodeString, relativeTo: url) { 176 | self = url 177 | return 178 | } 179 | 180 | let parts = unicodeString.urlParts 181 | 182 | if !parts.host.isEmpty { 183 | guard let encodedString = unicodeString.encodedURLString else { return nil } 184 | self.init(string: encodedString, relativeTo: url) 185 | } else { 186 | var allowedCharacters = CharacterSet.urlPathAllowed 187 | allowedCharacters.insert(charactersIn: "%?#") 188 | guard let encoded = unicodeString.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { return nil } 189 | self.init(string: encoded, relativeTo: url) 190 | } 191 | } 192 | 193 | } 194 | 195 | private extension StringProtocol { 196 | 197 | /// Punycode-encodes a string. 198 | /// 199 | /// Returns `nil` on error. 200 | /// - Todo: Throw errors on failure instead of returning `nil`. 201 | var punycodeEncoded: String? { 202 | var result = "" 203 | let scalars = self.unicodeScalars 204 | let inputLength = scalars.count 205 | 206 | var n = Punycode.initialN 207 | var delta: UInt32 = 0 208 | var outLen: UInt32 = 0 209 | var bias = Punycode.initialBias 210 | 211 | for scalar in scalars where scalar.isASCII { 212 | result.unicodeScalars.append(scalar) 213 | outLen += 1 214 | } 215 | 216 | let b: UInt32 = outLen 217 | var h: UInt32 = outLen 218 | 219 | if b > 0 { 220 | result.unicodeScalars.append(Punycode.delimiter) 221 | } 222 | 223 | // Main encoding loop: 224 | 225 | while h < inputLength { 226 | var m = UInt32.max 227 | 228 | for c in scalars { 229 | if c.value >= n && c.value < m { 230 | m = c.value 231 | } 232 | } 233 | 234 | if m - n > (UInt32.max - delta) / (h + 1) { 235 | return nil // overflow 236 | } 237 | 238 | delta += (m - n) * (h + 1) 239 | n = m 240 | 241 | for c in scalars { 242 | if c.value < n { 243 | delta += 1 244 | 245 | if delta == 0 { 246 | return nil // overflow 247 | } 248 | } 249 | 250 | if c.value == n { 251 | var q = delta 252 | var k = Punycode.base 253 | 254 | while true { 255 | let t = k <= bias ? Punycode.tmin : 256 | k >= bias + Punycode.tmax ? Punycode.tmax : k - bias 257 | 258 | if q < t { 259 | break 260 | } 261 | 262 | let encodedDigit = Punycode.encodeDigit(t + (q - t) % (Punycode.base - t)) 263 | 264 | result.unicodeScalars.append(UnicodeScalar(encodedDigit)!) 265 | q = (q - t) / (Punycode.base - t) 266 | 267 | k += Punycode.base 268 | } 269 | 270 | result.unicodeScalars.append(UnicodeScalar(Punycode.encodeDigit(q))!) 271 | bias = Punycode.adapt(delta: delta, numPoints: h + 1, firstTime: h == b) 272 | delta = 0 273 | h += 1 274 | } 275 | } 276 | 277 | delta += 1 278 | n += 1 279 | } 280 | 281 | return result 282 | } 283 | 284 | /// Punycode-decodes a string. 285 | /// 286 | /// Returns `nil` on error. 287 | /// - Todo: Throw errors on failure instead of returning `nil`. 288 | var punycodeDecoded: String? { 289 | var result = "" 290 | let scalars = self.unicodeScalars 291 | 292 | let endIndex = scalars.endIndex 293 | var n = Punycode.initialN 294 | var outLen: UInt32 = 0 295 | var i: UInt32 = 0 296 | var bias = Punycode.initialBias 297 | 298 | let b = scalars.lastIndex(of: "-") ?? scalars.startIndex 299 | 300 | for scalar in scalars[.. scalars.startIndex ? scalars.index(after: b) : scalars.startIndex 310 | 311 | while inPos < endIndex { 312 | var k = Punycode.base 313 | var w: UInt32 = 1 314 | let oldi = i 315 | 316 | while true { 317 | if inPos >= endIndex { 318 | return nil // bad input 319 | } 320 | 321 | let digit = Punycode.decodeDigit(scalars[inPos].value) 322 | 323 | inPos = scalars.index(after: inPos) 324 | 325 | if digit >= Punycode.base { return nil } // bad input 326 | if digit > (UInt32.max - i) / w { return nil } // overflow 327 | 328 | i += digit * w 329 | let t = k <= bias ? Punycode.tmin : 330 | k >= bias + Punycode.tmax ? Punycode.tmax : k - bias 331 | 332 | if digit < t { 333 | break 334 | } 335 | 336 | if w > UInt32.max / (Punycode.base - t) { return nil } // overflow 337 | 338 | w *= Punycode.base - t 339 | 340 | k += Punycode.base 341 | } 342 | 343 | bias = Punycode.adapt(delta: i - oldi, numPoints: outLen + 1, firstTime: oldi == 0) 344 | 345 | if i / (outLen + 1) > UInt32.max - n { return nil } // overflow 346 | 347 | n += i / (outLen + 1) 348 | i %= outLen + 1 349 | 350 | let index = result.unicodeScalars.index(result.unicodeScalars.startIndex, offsetBy: Int(i)) 351 | result.unicodeScalars.insert(UnicodeScalar(n)!, at: index) 352 | 353 | outLen += 1 354 | i += 1 355 | } 356 | 357 | return result 358 | } 359 | 360 | } 361 | 362 | private extension String { 363 | 364 | var urlParts: URLParts { 365 | let colonSlash = CharacterSet(charactersIn: ":/") 366 | let slashQuestion = CharacterSet(charactersIn: "/?") 367 | let s = Scanner(string: self) 368 | var scheme = "" 369 | var delim = "" 370 | var host = "" 371 | var path = "" 372 | var username: String? 373 | var password: String? 374 | var fragment: String? 375 | 376 | if let hostOrScheme = s.shimScanUpToCharacters(from: colonSlash) { 377 | let maybeDelim = s.shimScanCharacters(from: colonSlash) ?? "" 378 | 379 | if maybeDelim.hasPrefix(":") { 380 | delim = maybeDelim 381 | scheme = hostOrScheme 382 | host = s.shimScanUpToCharacters(from: slashQuestion) ?? "" 383 | } else { 384 | path.append(hostOrScheme) 385 | path.append(maybeDelim) 386 | } 387 | } else if let maybeDelim = s.shimScanString("//") { 388 | delim = maybeDelim 389 | 390 | if let maybeHost = s.shimScanUpToCharacters(from: slashQuestion) { 391 | host = maybeHost 392 | } 393 | } 394 | 395 | path.append(s.shimScanUpToString("#") ?? "") 396 | 397 | if s.shimScanString("#") != nil { 398 | fragment = s.shimScanUpToCharacters(from: .newlines) ?? "" 399 | } 400 | 401 | let usernamePasswordHostPort = host.components(separatedBy: "@") 402 | 403 | switch usernamePasswordHostPort.count { 404 | case 1: 405 | host = usernamePasswordHostPort[0] 406 | case 0: 407 | break // error 408 | default: 409 | let usernamePassword = usernamePasswordHostPort[0].components(separatedBy: ":") 410 | username = usernamePassword[0] 411 | password = usernamePassword.count > 1 ? usernamePassword[1] : nil 412 | host = usernamePasswordHostPort[1] 413 | } 414 | 415 | return URLParts(scheme: scheme, delim: delim, host: host, pathAndQuery: path, username: username, password: password, fragment: fragment) 416 | } 417 | 418 | enum UTS46MapError: Error { 419 | /// A disallowed codepoint was found in the string. 420 | case disallowedCodepoint(scalar: UnicodeScalar) 421 | } 422 | 423 | /// Perform a single-pass mapping using UTS #46. 424 | /// 425 | /// - Returns: The mapped string. 426 | /// - Throws: `UTS46Error`. 427 | func mapUTS46() throws -> String { 428 | try UTS46.loadIfNecessary() 429 | 430 | var result = "" 431 | 432 | for scalar in self.unicodeScalars { 433 | if UTS46.disallowedCharacters.contains(scalar) { 434 | throw UTS46MapError.disallowedCodepoint(scalar: scalar) 435 | } 436 | 437 | if UTS46.ignoredCharacters.contains(scalar) { 438 | continue 439 | } 440 | 441 | if let mapped = UTS46.characterMap[scalar.value] { 442 | result.append(mapped) 443 | } else { 444 | result.unicodeScalars.append(scalar) 445 | } 446 | } 447 | 448 | return result 449 | } 450 | 451 | var isValidLabel: Bool { 452 | guard self.precomposedStringWithCanonicalMapping.unicodeScalars.elementsEqual(self.unicodeScalars) else { return false } 453 | 454 | guard (try? self.mapUTS46()) != nil else { return false } 455 | 456 | if let category = self.unicodeScalars.first?.properties.generalCategory { 457 | if category == .nonspacingMark || category == .spacingMark || category == .enclosingMark { return false } 458 | } 459 | 460 | return self.hasValidJoiners 461 | } 462 | 463 | /// Whether a string's joiners (if any) are valid according to IDNA 2008 ContextJ. 464 | /// 465 | /// See [RFC 5892, Appendix A.1 and A.2](https://tools.ietf.org/html/rfc5892#appendix-A). 466 | var hasValidJoiners: Bool { 467 | try! UTS46.loadIfNecessary() 468 | 469 | let scalars = self.unicodeScalars 470 | 471 | for index in scalars.indices { 472 | let scalar = scalars[index] 473 | 474 | if scalar.value == 0x200C { // Zero-width non-joiner 475 | if index == scalars.startIndex { return false } 476 | 477 | var subindex = scalars.index(before: index) 478 | var previous = scalars[subindex] 479 | 480 | if previous.properties.canonicalCombiningClass == .virama { continue } 481 | 482 | while true { 483 | guard let joiningType = UTS46.joiningTypes[previous.value] else { return false } 484 | 485 | if joiningType == .transparent { 486 | if subindex == scalars.startIndex { 487 | return false 488 | } 489 | 490 | subindex = scalars.index(before: subindex) 491 | previous = scalars[subindex] 492 | } else if joiningType == .dual || joiningType == .left { 493 | break 494 | } else { 495 | return false 496 | } 497 | } 498 | 499 | subindex = scalars.index(after: index) 500 | var next = scalars[subindex] 501 | 502 | while true { 503 | if subindex == scalars.endIndex { 504 | return false 505 | } 506 | 507 | guard let joiningType = UTS46.joiningTypes[next.value] else { return false } 508 | 509 | if joiningType == .transparent { 510 | subindex = scalars.index(after: index) 511 | next = scalars[subindex] 512 | } else if joiningType == .right || joiningType == .dual { 513 | break 514 | } else { 515 | return false 516 | } 517 | } 518 | } else if scalar.value == 0x200D { // Zero-width joiner 519 | if index == scalars.startIndex { return false } 520 | 521 | let subindex = scalars.index(before: index) 522 | let previous = scalars[subindex] 523 | 524 | if previous.properties.canonicalCombiningClass != .virama { return false } 525 | } 526 | } 527 | 528 | return true 529 | } 530 | 531 | } 532 | 533 | private enum Punycode { 534 | static let base = UInt32(36) 535 | static let tmin = UInt32(1) 536 | static let tmax = UInt32(26) 537 | static let skew = UInt32(38) 538 | static let damp = UInt32(700) 539 | static let initialBias = UInt32(72) 540 | static let initialN = UInt32(0x80) 541 | static let delimiter: UnicodeScalar = "-" 542 | 543 | static func decodeDigit(_ cp: UInt32) -> UInt32 { 544 | return cp &- 48 < 10 ? cp &- 22 : cp &- 65 < 26 ? cp &- 65 : 545 | cp &- 97 < 26 ? cp &- 97 : Self.base 546 | } 547 | 548 | static func encodeDigit(_ d: UInt32, uppercase: Bool = false) -> UInt32 { 549 | // The extra UInt32 cast is to prevent typechecking from taking ages. 550 | return d + 22 + 75 * UInt32(d < 26 ? 1 : 0) - ((uppercase ? 1 : 0) << 5) 551 | } 552 | 553 | static let maxint = UInt32.max 554 | 555 | static func adapt(delta: UInt32, numPoints: UInt32, firstTime: Bool) -> UInt32 { 556 | var delta = delta 557 | 558 | delta = firstTime ? delta / Self.damp : delta >> 1 559 | delta += delta / numPoints 560 | 561 | var k: UInt32 = 0 562 | 563 | while delta > ((Self.base - Self.tmin) * Self.tmax) / 2 { 564 | delta /= Self.base - Self.tmin 565 | k += Self.base 566 | } 567 | 568 | return k + (Self.base - Self.tmin + 1) * delta / (delta + Self.skew) 569 | } 570 | } 571 | 572 | private struct URLParts { 573 | var scheme: String 574 | var delim: String 575 | var host: String 576 | var pathAndQuery: String 577 | 578 | var username: String? 579 | var password: String? 580 | var fragment: String? 581 | } 582 | -------------------------------------------------------------------------------- /jimmy/Utils/URLParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkParser.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 18/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | class URLParser { 11 | var baseURL: URL 12 | var link: String 13 | 14 | init(baseURL: URL, link: String) { 15 | self.baseURL = baseURL 16 | self.link = link 17 | } 18 | 19 | func toAbsolute() -> URL { 20 | // If link start with gemini, replace everything 21 | if link.contains("gemini://") { 22 | if let url = URL(unicodeString: link) { 23 | return url 24 | } 25 | } else { 26 | if let parsedUrl = URL(unicodeString: link, relativeTo: baseURL) { 27 | return parsedUrl 28 | } 29 | } 30 | return URL(string: "gemini://about")! 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /jimmy/Utils/UTS46+Loading.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UTS46+Loading.swift 3 | // icumap2code 4 | // 5 | // Created by Nate Weaver on 2020-05-08. 6 | // 7 | 8 | import Foundation 9 | import Compression 10 | import os 11 | 12 | extension UTS46 { 13 | 14 | private static func parseHeader(from data: Data) throws -> Header? { 15 | let headerData = data.prefix(12) 16 | 17 | guard headerData.count == 12 else { throw UTS46Error.badSize } 18 | 19 | return Header(rawValue: headerData) 20 | } 21 | 22 | static func load(from url: URL) throws { 23 | let fileData = try Data(contentsOf: url) 24 | 25 | guard let header = try? parseHeader(from: fileData) else { return } 26 | 27 | guard header.version == 1 else { throw UTS46Error.unknownVersion } 28 | 29 | let offset = header.dataOffset 30 | 31 | guard fileData.count > offset else { throw UTS46Error.badSize } 32 | 33 | switch header.version { 34 | case 1: 35 | break; 36 | default: 37 | if #available(macOS 10.12, iOS 10.0, tvOS 10.0, watchOS 3.0, *) { 38 | os_log("Unrecognized version found; assuming 1.", type: .debug) 39 | } else { 40 | print("Unrecognized version found; assuming 1.") 41 | } 42 | } 43 | 44 | let compressedData = fileData[offset...] 45 | 46 | if let crc = header.crc { 47 | guard crc == compressedData.crc32 else { throw UTS46Error.badCRC } 48 | } 49 | 50 | guard let data = self.decompress(data: compressedData, algorithm: header.compression) else { 51 | throw UTS46Error.decompressionError 52 | } 53 | 54 | var index = 0 55 | 56 | while index < data.count { 57 | let marker = data[index] 58 | 59 | index += 1 60 | 61 | switch marker { 62 | case Marker.characterMap: 63 | index = parseCharacterMap(from: data, start: index) 64 | case Marker.ignoredCharacters: 65 | index = parseIgnoredCharacters(from: data, start: index) 66 | case Marker.disallowedCharacters: 67 | index = parseDisallowedCharacters(from: data, start: index) 68 | case Marker.joiningTypes: 69 | index = parseJoiningTypes(from: data, start: index) 70 | default: 71 | throw UTS46Error.badMarker 72 | } 73 | } 74 | 75 | isLoaded = true 76 | } 77 | 78 | static var bundle: Bundle { 79 | #if SWIFT_PACKAGE 80 | return Bundle.module 81 | #else 82 | return Bundle(for: Self.self) 83 | #endif 84 | } 85 | 86 | static func loadIfNecessary() throws { 87 | guard !isLoaded else { return } 88 | guard let url = Self.bundle.url(forResource: "uts46", withExtension: nil) else { 89 | if #available(macOS 10.12, iOS 10.0, tvOS 10.0, watchOS 3.0, *) { 90 | os_log("uts46 data file is missing!", type: .error) 91 | } else { 92 | print("uts46 data file is missing!") 93 | } 94 | 95 | throw CocoaError(.fileNoSuchFile) 96 | } 97 | 98 | try load(from: url) 99 | } 100 | 101 | private static func decompress(data: Data, algorithm: CompressionAlgorithm?) -> Data? { 102 | 103 | guard let rawAlgorithm = algorithm?.rawAlgorithm else { return data } 104 | 105 | let capacity = 131_072 // 128 KB 106 | let destinationBuffer = UnsafeMutablePointer.allocate(capacity: capacity) 107 | 108 | let decompressed = data.withUnsafeBytes { (rawBuffer) -> Data? in 109 | let bound = rawBuffer.bindMemory(to: UInt8.self) 110 | let decodedCount = compression_decode_buffer(destinationBuffer, capacity, bound.baseAddress!, rawBuffer.count, nil, rawAlgorithm) 111 | 112 | if decodedCount == 0 || decodedCount == capacity { 113 | return nil 114 | } 115 | 116 | return Data(bytes: destinationBuffer, count: decodedCount) 117 | } 118 | 119 | return decompressed 120 | } 121 | 122 | private static func parseCharacterMap(from data: Data, start: Int) -> Int { 123 | characterMap.removeAll() 124 | var index = start 125 | 126 | main: while index < data.count { 127 | var accumulator = Data() 128 | 129 | while data[index] != Marker.sequenceTerminator { 130 | if data[index] > Marker.min { break main } 131 | 132 | accumulator.append(data[index]) 133 | index += 1 134 | } 135 | 136 | let str = String(data: accumulator, encoding: .utf8)! 137 | 138 | // FIXME: throw an error here. 139 | guard str.count > 0 else { continue } 140 | 141 | let codepoint = str.unicodeScalars.first!.value 142 | 143 | characterMap[codepoint] = String(str.unicodeScalars.dropFirst()) 144 | 145 | index += 1 146 | } 147 | 148 | return index 149 | } 150 | 151 | private static func parseRanges(from: String) -> [ClosedRange]? { 152 | guard from.unicodeScalars.count % 2 == 0 else { return nil } 153 | 154 | var ranges = [ClosedRange]() 155 | var first: UnicodeScalar? 156 | 157 | for (index, scalar) in from.unicodeScalars.enumerated() { 158 | if index % 2 == 0 { 159 | first = scalar 160 | } else if let first = first { 161 | ranges.append(first...scalar) 162 | } 163 | } 164 | 165 | return ranges 166 | } 167 | 168 | static func parseCharacterSet(from data: Data, start: Int) -> (index: Int, charset: CharacterSet?) { 169 | var index = start 170 | var accumulator = Data() 171 | 172 | while index < data.count, data[index] < Marker.min { 173 | accumulator.append(data[index]) 174 | index += 1 175 | } 176 | 177 | let str = String(data: accumulator, encoding: .utf8)! 178 | 179 | guard let ranges = parseRanges(from: str) else { 180 | return (index: index, charset: nil) 181 | } 182 | 183 | var charset = CharacterSet() 184 | 185 | for range in ranges { 186 | charset.insert(charactersIn: range) 187 | } 188 | 189 | return (index: index, charset: charset) 190 | } 191 | 192 | static func parseIgnoredCharacters(from data: Data, start: Int) -> Int { 193 | let (index, charset) = parseCharacterSet(from: data, start: start) 194 | 195 | if let charset = charset { 196 | ignoredCharacters = charset 197 | } 198 | 199 | return index 200 | } 201 | 202 | static func parseDisallowedCharacters(from data: Data, start: Int) -> Int { 203 | let (index, charset) = parseCharacterSet(from: data, start: start) 204 | 205 | if let charset = charset { 206 | disallowedCharacters = charset 207 | } 208 | 209 | return index 210 | } 211 | 212 | static func parseJoiningTypes(from data: Data, start: Int) -> Int { 213 | var index = start 214 | joiningTypes.removeAll() 215 | 216 | main: while index < data.count, data[index] < Marker.min { 217 | var accumulator = Data() 218 | 219 | while index < data.count { 220 | if data[index] > Marker.min { break main } 221 | accumulator.append(data[index]) 222 | 223 | index += 1 224 | } 225 | 226 | let str = String(data: accumulator, encoding: .utf8)! 227 | 228 | var type: JoiningType? 229 | var first: UnicodeScalar? 230 | 231 | for scalar in str.unicodeScalars { 232 | if scalar.isASCII { 233 | type = JoiningType(rawValue: Character(scalar)) 234 | } else if let type = type { 235 | if first == nil { 236 | first = scalar 237 | } else { 238 | for value in first!.value...scalar.value { 239 | joiningTypes[value] = type 240 | } 241 | 242 | first = nil 243 | } 244 | } 245 | } 246 | } 247 | 248 | return index 249 | } 250 | 251 | } 252 | -------------------------------------------------------------------------------- /jimmy/Utils/UTS46.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UTS46.swift 3 | // PunyCocoa Swift 4 | // 5 | // Created by Nate Weaver on 2020-03-29. 6 | // 7 | 8 | import Foundation 9 | import Compression 10 | 11 | /// UTS46 mapping. 12 | /// 13 | /// Storage file format. Codepoints are stored UTF-8-encoded. 14 | /// 15 | /// All multibyte integers are little-endian. 16 | /// 17 | /// Header: 18 | /// 19 | /// +--------------+---------+---------+----------+ 20 | /// | 6 bytes | 1 byte | 1 byte | 4 bytes? | 21 | /// +--------------+---------+---------+----------+ 22 | /// | magic number | version | flags | crc32 | 23 | /// +--------------+---------+---------+----------+ 24 | /// 25 | /// - `magic number`: `"UTS#46"` (`0x55 0x54 0x53 0x23 0x34 0x36`). 26 | /// - `version`: format version (1 byte; currently `0x01`). 27 | /// - `flags`: Bitfield: 28 | /// 29 | /// +-----+-----+-----+-----+-----+-----+-----+-----+ 30 | /// | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | 31 | /// +-----+-----+-----+-----+-----+-----+-----+-----+ 32 | /// | currently unused | crc | compression | 33 | /// +-----+-----+-----+-----+-----+-----+-----+-----+ 34 | /// 35 | /// - `crc32`: Optional CRC32 of the data after the header, if the `crc` flag is set. 36 | /// - `compression`: compression mode of the data. 37 | /// Currently identical to NSData's compression constants + 1: 38 | /// 39 | /// - 0: no compression 40 | /// - 1: LZFSE 41 | /// - 2: LZ4 42 | /// - 3: LZMA 43 | /// - 4: ZLIB 44 | /// 45 | /// - `crc32`: CRC32 of the (possibly compressed) data. Implementations can skip 46 | /// parsing this unless data integrity is an issue. 47 | /// 48 | /// The data section is a collection of data blocks of the format 49 | /// 50 | /// [marker][section data] ... 51 | /// 52 | /// Section data formats: 53 | /// 54 | /// If marker is `characterMap`: 55 | /// 56 | /// [codepoint][mapped-codepoint ...][null] ... 57 | /// 58 | /// If marker is `disallowedCharacters` or `ignoredCharacters`: 59 | /// 60 | /// [codepoint-range] ... 61 | /// 62 | /// If marker is `joiningTypes`: 63 | /// 64 | /// [type][[codepoint-range] ...] 65 | /// 66 | /// where `type` is one of `C`, `D`, `L`, `R`, or `T`. 67 | /// 68 | /// `codepoint-range`: two codepoints, marking the first and last codepoints of a 69 | /// closed range. Single-codepoint ranges have the same start and end codepoint. 70 | /// 71 | class UTS46 { 72 | 73 | static var characterMap: [UInt32: String] = [:] 74 | static var ignoredCharacters: CharacterSet = [] 75 | static var disallowedCharacters: CharacterSet = [] 76 | static var joiningTypes = [UInt32: JoiningType]() 77 | 78 | static var isLoaded = false 79 | 80 | enum Marker { 81 | static let characterMap = UInt8.max 82 | static let ignoredCharacters = UInt8.max - 1 83 | static let disallowedCharacters = UInt8.max - 2 84 | static let joiningTypes = UInt8.max - 3 85 | 86 | static let min = UInt8.max - 10 // No valid UTF-8 byte can fall here. 87 | 88 | static let sequenceTerminator: UInt8 = 0 89 | } 90 | 91 | enum JoiningType: Character { 92 | case causing = "C" 93 | case dual = "D" 94 | case right = "R" 95 | case left = "L" 96 | case transparent = "T" 97 | } 98 | 99 | enum UTS46Error: Error { 100 | case badSize 101 | case compressionError 102 | case decompressionError 103 | case badMarker 104 | case unknownVersion 105 | case badCRC 106 | } 107 | 108 | /// Identical values to `NSData.CompressionAlgorithm + 1`. 109 | enum CompressionAlgorithm: UInt8 { 110 | case none = 0 111 | case lzfse = 1 112 | case lz4 = 2 113 | case lzma = 3 114 | case zlib = 4 115 | 116 | var rawAlgorithm: compression_algorithm? { 117 | switch self { 118 | case .lzfse: 119 | return COMPRESSION_LZFSE 120 | case .lz4: 121 | return COMPRESSION_LZ4 122 | case .lzma: 123 | return COMPRESSION_LZMA 124 | case .zlib: 125 | return COMPRESSION_ZLIB 126 | default: 127 | return nil 128 | } 129 | } 130 | } 131 | 132 | struct Header: RawRepresentable, CustomDebugStringConvertible { 133 | typealias RawValue = [UInt8] 134 | 135 | var rawValue: [UInt8] { 136 | var value = Self.signature + [version, flags.rawValue] 137 | 138 | if self.hasCRC, let crc = crc { 139 | var crcValue = crc.littleEndian 140 | let crcData = Data(bytes: &crcValue, count: MemoryLayout.stride(ofValue: crcValue)) 141 | value += crcData 142 | 143 | assert(value.count == 12) 144 | } else { 145 | assert(value.count == 8) 146 | } 147 | 148 | return value 149 | } 150 | 151 | private static let compressionMask: UInt8 = 0x07 152 | private static let signature: [UInt8] = Array("UTS#46".utf8) 153 | 154 | private struct Flags: RawRepresentable { 155 | var rawValue: UInt8 { 156 | return (hasCRC ? hasCRCMask : 0) | compression.rawValue 157 | } 158 | 159 | let hasCRC: Bool 160 | let compression: CompressionAlgorithm 161 | 162 | private let hasCRCMask: UInt8 = 1 << 3 163 | private let compressionMask: UInt8 = 0x7 164 | 165 | init(rawValue: UInt8) { 166 | hasCRC = rawValue & hasCRCMask != 0 167 | let compressionBits = rawValue & compressionMask 168 | 169 | compression = CompressionAlgorithm(rawValue: compressionBits) ?? .none 170 | } 171 | 172 | init(compression: CompressionAlgorithm = .none, hasCRC: Bool = false) { 173 | self.compression = compression 174 | self.hasCRC = hasCRC 175 | } 176 | } 177 | 178 | let version: UInt8 179 | private var flags: Flags 180 | var hasCRC: Bool { flags.hasCRC } 181 | var crc: UInt32? 182 | var compression: CompressionAlgorithm { flags.compression } 183 | var dataOffset: Int { 8 + (flags.hasCRC ? 4 : 0) } 184 | 185 | init?(rawValue: T) where T: DataProtocol, T: ContiguousBytes, T.Index == Int { 186 | guard rawValue.count >= 8 else { return nil } 187 | guard rawValue.prefix(Self.signature.count).elementsEqual(Self.signature) else { return nil } 188 | 189 | version = rawValue[rawValue.index(rawValue.startIndex, offsetBy: 6)] 190 | flags = Flags(rawValue: rawValue[rawValue.index(rawValue.startIndex, offsetBy: 7)]) 191 | 192 | if flags.hasCRC { 193 | guard rawValue.count >= 12 else { return nil } 194 | 195 | let crcStart = rawValue.index(rawValue.startIndex, offsetBy: 8) 196 | 197 | crc = rawValue.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in 198 | return UInt32(littleEndian: buffer.load(fromByteOffset: crcStart, as: UInt32.self)) 199 | } 200 | 201 | } 202 | } 203 | 204 | init(compression: CompressionAlgorithm = .none, crc: UInt32?) { 205 | version = 1 206 | flags = Flags(compression: compression, hasCRC: crc != nil) 207 | self.crc = crc 208 | } 209 | 210 | var debugDescription: String { "has CRC: \(hasCRC); compression: \(String(describing: compression))" } 211 | } 212 | 213 | } 214 | -------------------------------------------------------------------------------- /jimmy/Utils/uts46: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/jimmy/Utils/uts46 -------------------------------------------------------------------------------- /jimmy/Views/AttributedText.swift: -------------------------------------------------------------------------------- 1 | // 2 | //MIT License 3 | // 4 | //Copyright (c) 2020 Guille Gonzalez 5 | // 6 | //Permission is hereby granted, free of charge, to any person obtaining a copy 7 | //of this software and associated documentation files (the "Software"), to deal 8 | //in the Software without restriction, including without limitation the rights 9 | //to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | //copies of the Software, and to permit persons to whom the Software is 11 | //furnished to do so, subject to the following conditions: 12 | // 13 | //The above copyright notice and this permission notice shall be included in all 14 | //copies or substantial portions of the Software. 15 | // 16 | //THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | //IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | //FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | //AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | //LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | //OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | //SOFTWARE. 23 | 24 | import SwiftUI 25 | 26 | /// A view that displays styled attributed text. 27 | public struct AttributedText: View { 28 | @StateObject var textSizeViewModel = TextSizeViewModel() 29 | @Binding var scrollPos: Double? 30 | 31 | private let attributedText: NSAttributedString 32 | private let onOpenLink: ((URL) -> Void)? 33 | private let onHoverLink: ((URL?, Bool) -> Void)? 34 | 35 | /// Creates an attributed text view. 36 | /// - Parameters: 37 | /// - attributedText: An attributed string to display. 38 | /// - onOpenLink: The action to perform when the user opens a link in the text. When not specified, 39 | /// the view opens the links using the `OpenURLAction` from the environment. 40 | public init(_ attributedText: NSAttributedString, onOpenLink: ((URL) -> Void)? = nil, onHoverLink: ((URL?, Bool) -> Void)? = nil, scrollPos: Binding = Binding(get: {nil }, set: {v in })) { 41 | self.attributedText = attributedText 42 | self.onOpenLink = onOpenLink 43 | self.onHoverLink = onHoverLink 44 | self._scrollPos = scrollPos 45 | } 46 | 47 | 48 | public var body: some View { 49 | GeometryReader { geometry in 50 | AttributedTextImpl( 51 | attributedText: attributedText, 52 | maxLayoutWidth: geometry.maxWidth, 53 | textSizeViewModel: textSizeViewModel, 54 | onOpenLink: onOpenLink, 55 | onHoverLink: onHoverLink, 56 | scrollPosition: $scrollPos 57 | ) 58 | } 59 | .frame( 60 | idealWidth: textSizeViewModel.textSize?.width, 61 | idealHeight: textSizeViewModel.textSize?.height 62 | ) 63 | .fixedSize(horizontal: false, vertical: true) 64 | } 65 | } 66 | 67 | extension GeometryProxy { 68 | fileprivate var maxWidth: CGFloat { 69 | size.width - safeAreaInsets.leading - safeAreaInsets.trailing 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /jimmy/Views/AttributedTextImpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | //MIT License 3 | // 4 | //Copyright (c) 2020 Guille Gonzalez 5 | // 6 | //Permission is hereby granted, free of charge, to any person obtaining a copy 7 | //of this software and associated documentation files (the "Software"), to deal 8 | //in the Software without restriction, including without limitation the rights 9 | //to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | //copies of the Software, and to permit persons to whom the Software is 11 | //furnished to do so, subject to the following conditions: 12 | // 13 | //The above copyright notice and this permission notice shall be included in all 14 | //copies or substantial portions of the Software. 15 | // 16 | //THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | //IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | //FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | //AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | //LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | //OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | //SOFTWARE. 23 | 24 | import Foundation 25 | 26 | import SwiftUI 27 | import Cocoa 28 | 29 | final class TextSizeViewModel: ObservableObject { 30 | @Published var textSize: CGSize? 31 | 32 | func didUpdateTextView(_ textView: AttributedTextImpl.TextView) { 33 | textSize = textView.intrinsicContentSize 34 | } 35 | } 36 | 37 | struct AttributedTextImpl { 38 | var attributedText: NSAttributedString 39 | var maxLayoutWidth: CGFloat 40 | var textSizeViewModel: TextSizeViewModel 41 | var onOpenLink: ((URL) -> Void)? 42 | var onHoverLink: ((URL?, Bool) -> Void)? 43 | 44 | @Binding var scrollPosition: Double? 45 | } 46 | 47 | extension AttributedTextImpl: NSViewRepresentable { 48 | func makeNSView(context: Context) -> TextView { 49 | let nsView = TextView(frame: .zero) 50 | 51 | nsView.onLinkHover = self.onHoverLink 52 | 53 | nsView.drawsBackground = false 54 | nsView.textContainerInset = .zero 55 | nsView.isEditable = false 56 | nsView.isRichText = false 57 | nsView.textContainer?.lineFragmentPadding = 0 58 | // we are setting the container's width manually 59 | nsView.textContainer?.widthTracksTextView = false 60 | nsView.linkTextAttributes = [ 61 | NSAttributedString.Key.foregroundColor: NSColor.controlAccentColor 62 | ] 63 | 64 | nsView.displaysLinkToolTips = false 65 | nsView.delegate = context.coordinator 66 | 67 | return nsView 68 | } 69 | 70 | func updateNSView(_ nsView: TextView, context: Context) { 71 | nsView.textStorage?.setAttributedString(attributedText) 72 | nsView.maxLayoutWidth = maxLayoutWidth 73 | nsView.onLinkHover = self.onHoverLink 74 | 75 | nsView.linkTextAttributes = [ 76 | NSAttributedString.Key.foregroundColor: NSColor.controlAccentColor 77 | ] 78 | nsView.alllinks = [] 79 | 80 | nsView.textContainer?.maximumNumberOfLines = context.environment.lineLimit ?? 0 81 | nsView.textContainer?.lineBreakMode = NSLineBreakMode( 82 | truncationMode: context.environment.truncationMode 83 | ) 84 | context.coordinator.openLink = onOpenLink ?? { context.environment.openURL($0) } 85 | textSizeViewModel.didUpdateTextView(nsView) 86 | //Find green range and scroll to it 87 | guard let storage = nsView.textStorage else { return } 88 | let wholeRange = NSRange(nsView.string.startIndex..., in: nsView.string) 89 | storage.enumerateAttribute(.backgroundColor, in: wholeRange, options: []) { (value, range, pointee) in 90 | if let v = value as? NSColor { 91 | if v == NSColor.green { 92 | nsView.scrollRangeToVisible(range) 93 | } 94 | } 95 | } 96 | // print("scroll", scrollPosition) 97 | //nsView.scroll(NSPoint(x: 0, y: (scrollPosition ?? 0.0))) 98 | 99 | nsView.scrollToVisible(NSRect(x: 0, y: Int(scrollPosition ?? 0.0), width: 1, height: 1)) 100 | 101 | } 102 | 103 | func makeCoordinator() -> Coordinator { 104 | Coordinator() 105 | } 106 | } 107 | 108 | extension AttributedTextImpl { 109 | 110 | final class TextView: NSTextView { 111 | var wasHovered: Bool = false 112 | var maxLayoutWidth: CGFloat { 113 | get { textContainer?.containerSize.width ?? 0 } 114 | set { 115 | guard textContainer?.containerSize.width != newValue else { return } 116 | textContainer?.containerSize.width = newValue 117 | invalidateIntrinsicContentSize() 118 | } 119 | } 120 | 121 | func hoveringLink(url: URL?, hovered: Bool) { 122 | if let onlinkHover = onLinkHover { 123 | onlinkHover(url, hovered) 124 | } 125 | } 126 | 127 | var onLinkHover: ((URL?, Bool) -> Void)? = nil 128 | 129 | var alllinks: [AttributedStringLink] = [] 130 | 131 | override func mouseMoved(with event: NSEvent) { 132 | super.mouseMoved(with: event) 133 | 134 | guard let point = event.window?.convertPoint(toScreen: event.locationInWindow) else { return } 135 | 136 | var char = self.characterIndex(for: point) 137 | 138 | let rect = self.firstRect(forCharacterRange: NSRange(location: char, length: 1), actualRange: nil) 139 | 140 | let mouseOnChar = NSPointInRect(point, rect) 141 | 142 | guard let storage = self.textStorage else { return } 143 | if char > self.string.count { 144 | char = self.string.count 145 | } 146 | 147 | let wholeRange = NSRange(self.string.startIndex..., in: self.string) 148 | let attributes = storage.attributes(at: char, effectiveRange: nil) 149 | 150 | var hoveredUrl: URL? = nil 151 | 152 | if let url = attributes[.link] as? URL { 153 | 154 | storage.enumerateAttribute(.link, in: wholeRange, options: []) { (value, range, pointee) in 155 | if let u = value as? URL { 156 | 157 | if url == u && range.contains(char) && mouseOnChar { 158 | // Hovering this link 159 | hoveredUrl = url 160 | wasHovered = true 161 | // self.addCursorRect(self.bounds, cursor: .pointingHand) 162 | // storage.removeAttribute(.link, range: range) 163 | self.linkTextAttributes = [ 164 | NSAttributedString.Key.foregroundColor: NSColor.green.blended(withFraction: 0.5, of: NSColor.controlAccentColor) ?? NSColor.green 165 | ] 166 | 167 | storage.addAttribute(.foregroundColor, value: NSColor.green.blended(withFraction: 0.5, of: NSColor.controlAccentColor) ?? NSColor.green, range: range) 168 | } else { 169 | 170 | // not hovering this link 171 | storage.removeAttribute(.link, range: range) 172 | storage.addAttribute(.foregroundColor, value: NSColor.controlAccentColor, range: range) 173 | alllinks.append(AttributedStringLink(url: u, range: range)) 174 | } 175 | } 176 | } 177 | } else { 178 | // not a link 179 | 180 | self.linkTextAttributes = [ 181 | NSAttributedString.Key.foregroundColor: NSColor.controlAccentColor 182 | ] 183 | for oldlink in alllinks { 184 | storage.addAttribute(.link, value: oldlink.url, range: oldlink.range) 185 | } 186 | hoveredUrl = nil 187 | if wasHovered { 188 | // self.addCursorRect(self.bounds, cursor: .iBeam) 189 | hoveringLink(url: nil, hovered: false) 190 | wasHovered = false 191 | } 192 | } 193 | 194 | if let hu = hoveredUrl { 195 | self.hoveringLink(url: hu, hovered: true) 196 | } 197 | } 198 | 199 | override func menu(for event: NSEvent) -> NSMenu? { 200 | let menu = super.menu(for: event) 201 | guard let point = event.window?.convertPoint(toScreen: event.locationInWindow) else { return menu } 202 | 203 | let char = self.characterIndex(for: point) 204 | 205 | guard let storage = self.textStorage else { return menu } 206 | 207 | let attributes = storage.attributes(at: char, effectiveRange: nil) 208 | 209 | 210 | if let url = attributes[.link] as? URL { 211 | let item = CustomMenuItem(title: String(localized: "Open Link in New Tab"), action: #selector(self.newTab), keyEquivalent: "") 212 | 213 | item.url = url 214 | 215 | menu?.insertItem(item, at: 1) 216 | } 217 | 218 | return menu 219 | } 220 | @objc func newTab(_ sender: CustomMenuItem) { 221 | if let url = sender.url { 222 | self.linkTextAttributes = [ 223 | NSAttributedString.Key.foregroundColor: NSColor.controlAccentColor 224 | ] 225 | self.alllinks = [] 226 | NSWorkspace.shared.open(url) 227 | } 228 | } 229 | override var intrinsicContentSize: NSSize { 230 | guard maxLayoutWidth > 0, 231 | let textContainer = self.textContainer, 232 | let layoutManager = self.layoutManager 233 | else { 234 | return super.intrinsicContentSize 235 | } 236 | 237 | layoutManager.ensureLayout(for: textContainer) 238 | return layoutManager.usedRect(for: textContainer).size 239 | } 240 | 241 | } 242 | 243 | final class Coordinator: NSObject, NSTextViewDelegate { 244 | var openLink: ((URL) -> Void)? 245 | 246 | func textView(_: NSTextView, clickedOnLink link: Any, at _: Int) -> Bool { 247 | guard let openLink = self.openLink, 248 | let url = (link as? URL) ?? (link as? String).flatMap(URL.init(string:)) 249 | else { 250 | return false 251 | } 252 | 253 | if let scheme = url.scheme { 254 | if scheme == "gemini" { 255 | openLink(url) 256 | } else { 257 | NSWorkspace.shared.open(url) 258 | } 259 | } 260 | 261 | return true 262 | } 263 | 264 | } 265 | 266 | 267 | } 268 | 269 | class CustomMenuItem: NSMenuItem { 270 | var url: URL? 271 | } 272 | 273 | extension NSLineBreakMode { 274 | init(truncationMode: Text.TruncationMode) { 275 | switch truncationMode { 276 | case .head: 277 | self = .byTruncatingHead 278 | case .tail: 279 | self = .byTruncatingTail 280 | case .middle: 281 | self = .byTruncatingMiddle 282 | @unknown default: 283 | self = .byWordWrapping 284 | } 285 | } 286 | } 287 | 288 | 289 | struct AttributedStringLink { 290 | var url: URL 291 | var range: NSRange 292 | } 293 | -------------------------------------------------------------------------------- /jimmy/Views/BookmarkView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 19/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BookmarkView: View { 11 | @Environment(\.openURL) var openURL 12 | @EnvironmentObject private var bookmarks: Bookmarks 13 | 14 | private var tab: Tab 15 | private var bookmark: Bookmark 16 | @State private var isHover = false 17 | 18 | var close: () -> Void 19 | 20 | init(bookmark: Bookmark, tab: Tab, close: @escaping () -> Void) { 21 | self.tab = tab 22 | self.bookmark = bookmark 23 | self.close = close 24 | } 25 | 26 | var body: some View { 27 | HStack { 28 | Button(action: { 29 | bookmarks.remove(bookmark: bookmark) 30 | }) { 31 | Image(systemName: "xmark") 32 | } 33 | .buttonStyle(.plain) 34 | .padding(4).padding(.leading, 8).padding(.trailing, 0) 35 | 36 | ZStack { 37 | Button(action: { 38 | tab.url = bookmark.url 39 | tab.load() 40 | close() 41 | }) { 42 | Text(tab.emojis.emoji(bookmark.url.host ?? "")) 43 | Text((bookmark.url.absoluteString.decodedURLString ?? bookmark.url.absoluteString).replacingOccurrences(of: "gemini://", with: "")).frame(maxWidth: .infinity, alignment: .leading) 44 | 45 | } 46 | 47 | .buttonStyle(.borderless) 48 | .frame(maxWidth: .infinity, alignment: .leading) 49 | .padding(4).padding(.leading, 0).padding(.trailing, 8) 50 | .contextMenu { 51 | VStack { 52 | Button(action: { 53 | newTab(self.bookmark.url) 54 | }) 55 | { 56 | Label("Open in new tab", systemImage: "plus.rectangle") 57 | } 58 | .buttonStyle(.plain) 59 | Button(action: copyLink) 60 | { 61 | Label("Copy link address", systemImage: "link") 62 | } 63 | .buttonStyle(.plain) 64 | } 65 | } 66 | } 67 | .frame(maxWidth: .infinity, alignment: .leading) 68 | .background(isHover ? Color("urlbackground") : Color.clear) 69 | .animation(.spring(), value: isHover) 70 | .onHover { hover in 71 | isHover = hover 72 | } 73 | .clipShape(RoundedRectangle(cornerRadius: 4)) 74 | } 75 | 76 | } 77 | 78 | func newTab(_ url: URL) { 79 | openURL(url) 80 | } 81 | 82 | func copyLink() { 83 | NSPasteboard.general.clearContents() 84 | NSPasteboard.general.setString(self.bookmark.url.absoluteString, forType: .string) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /jimmy/Views/BookmarksView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookmarkView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 19/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BookmarksView: View { 11 | @EnvironmentObject private var bookmarks: Bookmarks 12 | 13 | var tab: Tab 14 | 15 | var close: () -> Void 16 | 17 | init(tab: Tab, close: @escaping () -> Void) { 18 | self.tab = tab 19 | self.close = close 20 | } 21 | 22 | var body: some View { 23 | VStack { 24 | Text("Bookmarks").frame(maxWidth: .infinity) 25 | Divider() 26 | ForEach(bookmarks.items) { bookmark in 27 | BookmarkView(bookmark: bookmark, tab: tab, close: close) 28 | } 29 | }.padding() 30 | .frame(maxWidth: .infinity, alignment: .leading) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /jimmy/Views/CommandsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandsView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 23/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommandsView: View { 11 | @Environment(\.openURL) var openURL 12 | @EnvironmentObject var actions: Actions 13 | var body: some View { 14 | Button("New Tab") { openURL(URL(string: "gemini://about")!) }.keyboardShortcut("t") 15 | Divider() 16 | Button("Reload") { 17 | actions.reload += 1 18 | }.keyboardShortcut("r") 19 | 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /jimmy/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 16/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | struct ContentView: View { 12 | 13 | @EnvironmentObject var bookmarks: Bookmarks 14 | @EnvironmentObject var actions: Actions 15 | @EnvironmentObject var history: History 16 | @StateObject var tab: Tab = Tab(url: URL(string: "gemini://about")!) 17 | @State var showPopover = false 18 | @State private var old = 0 19 | @State private var rotation = 0.0 20 | @State var showHistorySearch = false 21 | @State var urlsearch = "" 22 | @State var typing = false 23 | @GestureState var isDetectingLongPress = false 24 | 25 | let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() 26 | 27 | init() { 28 | 29 | } 30 | 31 | var body: some View { 32 | GeometryReader { geometry in 33 | VStack { 34 | TabContentWrapperView(tab: tab, close: { 35 | DispatchQueue.main.async { 36 | showHistorySearch = false 37 | } 38 | }) 39 | } 40 | .onReceive(Just(actions.reload)) { val in 41 | //tab.load() 42 | if old != val { 43 | old = val 44 | DispatchQueue.main.async{ 45 | tab.load() 46 | } 47 | } 48 | 49 | } 50 | .navigationTitle(tab.emojis.emoji(tab.url.host ?? "") + " " + (tab.url.host?.idnaDecoded ?? "")) 51 | 52 | .frame(maxWidth: .infinity, minHeight: 200) 53 | .toolbar{ 54 | urlToolBarContent(geometry) 55 | } 56 | 57 | .onOpenURL(perform: { url in 58 | tab.url = url 59 | DispatchQueue.main.async { 60 | self.showHistorySearch = false 61 | } 62 | tab.load() 63 | }) 64 | .onDisappear(perform: { 65 | print("disappearing", getCurrentWindows().count) 66 | DispatchQueue.main.async { 67 | let w = getCurrentWindows() 68 | if w.count == 1 && (w.first!.tabGroup == nil || w.first!.tabGroup?.isTabBarVisible == false) { 69 | w.first!.toggleTabBar(self) 70 | } 71 | } 72 | }) 73 | .onAppear(perform: { 74 | tab.setHistory(history) 75 | DispatchQueue.main.async { 76 | 77 | guard let firstWindow = NSApp.windows.first(where: { win in 78 | return (NSStringFromClass(type(of: win)) == "SwiftUI.AppKitWindow" || NSStringFromClass(type(of: win)) == "SwiftUI.SwiftUIWindow") 79 | }) else { return } 80 | 81 | 82 | //firstWindow.makeKeyAndOrderFront(nil) 83 | var group = firstWindow 84 | if let g = firstWindow.tabGroup?.selectedWindow { 85 | group = g 86 | } 87 | let w = getCurrentWindows() 88 | print(w.count) 89 | 90 | if w.count == 1 && (w.first!.tabGroup == nil || w.first!.tabGroup?.isTabBarVisible == false) { 91 | w.first!.toggleTabBar(self) 92 | } else if w.count > 1 && NSApp.keyWindow?.tabGroup?.isTabBarVisible == true { 93 | NSApp.keyWindow?.toggleTabBar(self) 94 | } 95 | 96 | let lastWindow = NSApp.windows.first(where: {win in 97 | return win.tabbedWindows?.count == nil && (NSStringFromClass(type(of: win)) == "SwiftUI.AppKitWindow" || NSStringFromClass(type(of: win)) == "SwiftUI.SwiftUIWindow") && win != group 98 | }) 99 | 100 | NSApp.windows.forEach({win in 101 | let className = NSStringFromClass(type(of: win)) 102 | if win != firstWindow && (className == "SwiftUI.SwiftUIWindow" || className == "SwiftUI.AppKitWindow") && win.tabbedWindows?.count == nil { 103 | group.addTabbedWindow(win, ordered: .above) 104 | } 105 | }) 106 | 107 | if let last = lastWindow { 108 | last.makeKeyAndOrderFront(nil) 109 | } 110 | tab.load() 111 | } 112 | }) 113 | } 114 | } 115 | 116 | @ToolbarContentBuilder 117 | func urlToolBarContent(_ geometry: GeometryProxy) -> some ToolbarContent { 118 | let url = Binding( 119 | get: { tab.url.absoluteString.decodedURLString! }, 120 | set: { s in 121 | urlsearch = s 122 | tab.url = URL(unicodeString: s) ?? URL(string: "gemini://about")! 123 | } 124 | ) 125 | 126 | ToolbarItem(placement: .navigation) { // (1) we can specify location for each ToolbarItem 127 | let press = LongPressGesture(minimumDuration: 3) 128 | .updating($isDetectingLongPress) { currentState, gestureState, transaction in 129 | print(currentState, transaction) 130 | gestureState = currentState 131 | } 132 | Button(action: back) { 133 | Image(systemName: "arrow.backward").imageScale(.large).padding(.trailing, 8) 134 | } 135 | .disabled(tab.history.count <= 1) 136 | .buttonStyle(.borderless) 137 | .gesture(press) 138 | 139 | } 140 | 141 | ToolbarItemGroup(placement: .principal) { 142 | 143 | ZStack(alignment: .trailing) { 144 | 145 | TextField("gemini://", text: url, onEditingChanged: { focused in 146 | typing = focused 147 | }) 148 | .onSubmit { 149 | go() 150 | } 151 | .onChange(of: urlsearch, perform: { u in 152 | showHistorySearch = history.items.contains(where: { hist in 153 | hist.url.absoluteString.replacingOccurrences(of: "gemini://", with: "").contains(u.replacingOccurrences(of: "gemini://", with: "")) 154 | }) && typing && u.starts(with: "gemini://") 155 | if !u.starts(with: "gemini://") { 156 | urlsearch = "gemini://" + u 157 | } 158 | }) 159 | .popover(isPresented: $showHistorySearch, attachmentAnchor: .point(.bottom), arrowEdge: .bottom , content: { 160 | HistoryView(close: { 161 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { 162 | self.showHistorySearch = false 163 | } 164 | }) 165 | .environmentObject(tab) 166 | }) 167 | 168 | .frame(minWidth: 300, idealWidth: geometry.size.width/2, maxWidth: .infinity) 169 | 170 | .background(Color("urlbackground")) 171 | .clipShape(RoundedRectangle(cornerRadius: 8)) 172 | .textFieldStyle(.roundedBorder) 173 | if (tab.loading) { 174 | Image(systemName: "arrow.triangle.2.circlepath") 175 | .foregroundColor(Color.gray) 176 | .rotationEffect(Angle(degrees: rotation)) 177 | .onReceive(timer) { time in 178 | $rotation.wrappedValue += 1.0 179 | } 180 | .padding(.trailing, 8) 181 | 182 | } 183 | 184 | Button(action: toggleValidateCert) { 185 | Image(systemName: (tab.ignoredCertValidation ? "lock.open" : "lock")) 186 | .foregroundColor((tab.ignoredCertValidation ? Color.red : Color.green)) 187 | .imageScale(.large).padding(.leading, 0) 188 | .opacity(0.7) 189 | }.disabled(!tab.ignoredCertValidation) 190 | .padding(.trailing, tab.loading ? 20 : 0) 191 | } 192 | 193 | Button(action: go) { 194 | Image(systemName: (tab.loading ? "xmark" : "arrow.clockwise")) 195 | .imageScale(.large).padding(.leading, 0) 196 | } 197 | .buttonStyle(.borderless) 198 | .disabled(url.wrappedValue.isEmpty) 199 | 200 | Spacer(minLength: 50) 201 | } 202 | 203 | ToolbarItemGroup(placement: .primaryAction, content: { 204 | Button(action: bookmark) { 205 | Image(systemName: (bookmarked ? "star.fill" : "star")).imageScale(.large) 206 | } 207 | .buttonStyle(.borderless) 208 | .disabled(url.wrappedValue.isEmpty) 209 | Button(action: showBookmarks) { 210 | Image(systemName: "bookmark").imageScale(.large) 211 | } 212 | .buttonStyle(.borderless) 213 | .popover(isPresented: $showPopover, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { 214 | BookmarksView(tab: tab, close: { showPopover = false }).frame(maxWidth: .infinity) 215 | } 216 | }) 217 | 218 | } 219 | 220 | 221 | func showBookmarks() { 222 | self.showPopover = !self.showPopover 223 | } 224 | 225 | var bookmarked: Bool { 226 | return bookmarks.items.contains(where: { $0.url == tab.url }) 227 | } 228 | 229 | func bookmark() { 230 | if (bookmarked) { 231 | bookmarks.items = bookmarks.items.filter( { $0.url != tab.url } ) 232 | } else { 233 | bookmarks.items.append(Bookmark(url: tab.url)) 234 | } 235 | 236 | bookmarks.save() 237 | } 238 | 239 | func go() { 240 | 241 | if (tab.loading) { 242 | tab.stop() 243 | } else { 244 | if !tab.url.absoluteString.starts(with: "gemini://") { 245 | let u = tab.url.absoluteString 246 | tab.url = URL(string: "gemini://" + u) ?? URL(string: "gemini://about/")! 247 | } 248 | 249 | tab.load() 250 | } 251 | DispatchQueue.main.async { 252 | showHistorySearch = false 253 | } 254 | } 255 | 256 | func back() { 257 | tab.back() 258 | DispatchQueue.main.async { 259 | showHistorySearch = false 260 | } 261 | } 262 | 263 | func getCurrentWindows() -> [NSWindow] { 264 | return NSApp.windows.filter{ NSStringFromClass(type(of: $0)) == "SwiftUI.SwiftUIWindow" } 265 | } 266 | 267 | func toggleValidateCert() { 268 | print("ignored cert validation", tab.certs.items.contains(tab.url.host ?? "")) 269 | if tab.certs.items.contains(tab.url.host ?? "") { 270 | tab.certs.items.removeAll(where: {$0 == tab.url.host}) 271 | tab.load() 272 | } else { 273 | tab.certs.items.append(tab.url.host ?? "") 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /jimmy/Views/HistoryItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryItemView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 02/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HistoryItemView: View { 11 | @State var hovered = false 12 | @EnvironmentObject var tab: Tab 13 | var item: HistoryItem 14 | 15 | var close: () -> Void 16 | 17 | var body: some View { 18 | Button(action: { 19 | tab.url = item.url 20 | tab.load() 21 | self.close() 22 | }) { 23 | HStack { 24 | Text(tab.emojis.emoji(item.url.host ?? "")) 25 | .font(.system(size: 24)) 26 | VStack { 27 | Text(item.url.absoluteString.replacingOccurrences(of: "gemini://", with: "")) 28 | .font(.system(size: 16, weight: .light, design: .default)) 29 | .frame(minWidth: 600, maxWidth: .infinity, alignment: .leading) 30 | 31 | Text(item.snippet) 32 | .font(.system(size: 14, weight: .semibold, design: .default)) 33 | .frame(minWidth: 600, maxWidth: .infinity, alignment: .leading) 34 | 35 | } 36 | } 37 | 38 | .padding(6) 39 | .padding(.leading, 8) 40 | .padding(.trailing, 8) 41 | } 42 | 43 | .buttonStyle(.borderless) 44 | 45 | .onHover(perform: { hover in 46 | hovered = hover 47 | tab.status = item.url.absoluteString.replacingOccurrences(of: "gemini://", with: "") 48 | }) 49 | .background(hovered ? Color.accentColor : Color.clear) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /jimmy/Views/HistoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 02/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HistoryView: View { 11 | @EnvironmentObject var history: History 12 | @EnvironmentObject var tab: Tab 13 | @State var hoverClearHistoryButton = false 14 | 15 | var close: () -> Void 16 | 17 | var body: some View { 18 | let histItems = history.items.filter({ hist in 19 | hist.url.absoluteString.replacingOccurrences(of: "gemini://", with: "").contains(tab.url.absoluteString.replacingOccurrences(of: "gemini://", with: "")) 20 | }) 21 | ZStack(alignment: .topTrailing){ 22 | ScrollView { 23 | VStack(alignment: .leading, spacing: 0) { 24 | ForEach(histItems, id: \.self) { item in 25 | HistoryItemView(item: item, close: { 26 | close() 27 | }).environmentObject(tab) 28 | } 29 | } 30 | } 31 | .frame(maxHeight: 400) 32 | Button(action: { 33 | clearHistory() 34 | }) { 35 | HStack { 36 | Image(systemName: "clear.fill") 37 | if hoverClearHistoryButton { 38 | Text("Clear history") 39 | } 40 | } 41 | .padding(.top, 16) 42 | .padding(.trailing, 20) 43 | .padding(.leading, 8) 44 | .padding(.bottom, 4) 45 | .animation(.default, value: hoverClearHistoryButton) 46 | .background(Color.gray.opacity(0.5)) 47 | .clipShape(RoundedRectangle(cornerRadius: 4)) 48 | } 49 | .buttonStyle(.plain) 50 | .onHover(perform: { hover in 51 | hoverClearHistoryButton = hover 52 | }) 53 | .padding(.trailing, -12) 54 | .padding(.top, -12) 55 | 56 | } 57 | .frame(maxHeight: 400) 58 | 59 | } 60 | 61 | func clearHistory() { 62 | history.clear() 63 | close() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /jimmy/Views/LineView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LineView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 17/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LineView: View, Hashable { 11 | @EnvironmentObject var certs: IgnoredCertificates 12 | var line: String 13 | var data: Data 14 | var type: String 15 | var tab: Tab 16 | var attrStr: NSAttributedString? 17 | var id: UUID 18 | 19 | @State var answer = "" 20 | 21 | init(data: Data, type: String, tab:Tab) { 22 | self.line = String(decoding: data, as: UTF8.self) 23 | self.data = data 24 | 25 | self.type = type 26 | self.id = UUID() 27 | self.tab = tab 28 | } 29 | 30 | init(attributed: NSAttributedString, tab: Tab) { 31 | self.id = UUID() 32 | self.tab = tab 33 | self.type = "text" 34 | self.line = "" 35 | self.data = Data([]) 36 | self.attrStr = attributed 37 | } 38 | 39 | var body: some View { 40 | textView 41 | } 42 | 43 | @ViewBuilder 44 | private var textView: some View { 45 | if type.starts(with: "text/ignore-cert") { 46 | let format = NSLocalizedString("Ignore certificate validation for %@%@", comment:"Button label to ignore certificate validation for this host") 47 | 48 | Button(action: { 49 | if let host = tab.url.host { 50 | certs.items.append(host) 51 | tab.certs.items = certs.items 52 | certs.save() 53 | tab.load() 54 | } 55 | }, label: { 56 | Text(String(format: format, tab.emojis.emoji(tab.url.host ?? ""), (tab.url.host ?? ""))) 57 | }) 58 | } else if type.starts(with: "text/answer") { 59 | // Line for an answer. The question should be above this 60 | HStack { 61 | TextField("Answer", text: $answer) 62 | .textFieldStyle(.roundedBorder) 63 | .onSubmit { 64 | send() 65 | } 66 | Button(action: send) { 67 | Text("Send") 68 | } 69 | } 70 | } else if type.starts(with: "image/") { 71 | // Line for an answer. The question should be above this 72 | if let img = NSImage(data: Data(self.data)) { 73 | Image(nsImage: img) 74 | .resizable() 75 | .aspectRatio(contentMode: .fit) 76 | .layoutPriority(-1) 77 | } else { 78 | Image(systemName: "xmark") 79 | } 80 | } else { 81 | if let a = attrStr { 82 | AttributedText(a) 83 | } 84 | } 85 | } 86 | 87 | func send () { 88 | if let url = URL(string: tab.url.absoluteString + "?" + (answer.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")) { 89 | tab.url = url 90 | tab.load() 91 | } 92 | } 93 | 94 | static func == (lhs: LineView, rhs: LineView) -> Bool { 95 | return lhs.id == rhs.id 96 | } 97 | 98 | func hash(into hasher: inout Hasher) { 99 | hasher.combine(id) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /jimmy/Views/TabContentWrapperView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabContentView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 18/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TabContentWrapperView: View { 11 | @ObservedObject var tab: Tab 12 | @State var text = "" 13 | 14 | var close: () -> Void 15 | 16 | var body: some View { 17 | tabView 18 | } 19 | 20 | @ViewBuilder 21 | private var tabView: some View { 22 | ZStack(alignment: .bottomLeading) { 23 | HStack { 24 | ScrollView { 25 | if (tab.content.count > 0) { 26 | TabLineView(tab: tab) 27 | } else { 28 | TabTextView(tab: tab, close: close) 29 | } 30 | } 31 | 32 | .frame(minWidth: 200, maxWidth: .infinity, alignment: .leading) 33 | .background(Color("background")) 34 | .coordinateSpace(name: "scroll") 35 | } 36 | status 37 | } 38 | } 39 | 40 | @ViewBuilder 41 | private var status: some View { 42 | if !tab.status.isEmpty { 43 | HStack { 44 | Text(tab.status) 45 | .font(.system(size: 12, weight: .light)) 46 | .padding(.leading, 24) 47 | .padding(.trailing, 8) 48 | .padding(.bottom, 8) 49 | .padding(.top, 2) 50 | .opacity(0.7) 51 | .background(Color("background").opacity(0.8)) 52 | .clipShape(RoundedRectangle(cornerRadius: 4)) 53 | .overlay(RoundedRectangle(cornerRadius: 4).stroke(lineWidth: 1).background(.clear).foregroundColor(Color("urlbackground"))) 54 | 55 | } 56 | .padding(.leading, -12) 57 | .padding(.bottom, -4) 58 | } 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /jimmy/Views/TabLineView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabLineView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 27/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TabLineView: View { 11 | @ObservedObject var tab: Tab 12 | @State var search = "" 13 | var body: some View { 14 | ForEach(tab.content, id: \.self) { view in 15 | view 16 | .textSelection(.enabled) 17 | .frame(minWidth: 200, maxWidth: 800, alignment: .leading) 18 | .id(view.id) 19 | } 20 | .padding(48) 21 | .frame(minWidth: 200, maxWidth: .infinity, alignment: .center) 22 | .searchable(text: $search) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /jimmy/Views/TabTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabContentView.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 27/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TabTextView: View { 11 | 12 | @ObservedObject var tab: Tab 13 | @State var text = "" 14 | @State var textRanges: [Range] = [] 15 | 16 | var close: () -> Void 17 | 18 | @ViewBuilder 19 | var textView: some View { 20 | let scroll = Binding( 21 | get: { 22 | tab.scrollPos 23 | }, 24 | set: {val in 25 | tab.scrollPos = val ?? 0.0 26 | } 27 | ) 28 | 29 | AttributedText( 30 | tab.textContent, 31 | onOpenLink: { url in 32 | tab.url = url 33 | tab.load() 34 | close() 35 | }, 36 | onHoverLink: { url, hovered in 37 | let loadingStatus = tab.loading ? "Loading " + tab.url.absoluteString : "" 38 | if let u = url { 39 | let newStatus = hovered ? (u.absoluteString.decodedURLString ?? u.absoluteString).replacingOccurrences(of: "gemini://", with: "") : loadingStatus 40 | if tab.status != newStatus { 41 | tab.status = newStatus 42 | } 43 | } 44 | if hovered == false { 45 | tab.status = loadingStatus 46 | } 47 | }, 48 | scrollPos: scroll 49 | ) 50 | .background(GeometryReader { 51 | Color.clear.preference(key: ViewOffsetKey.self, 52 | value: -$0.frame(in: .named("scroll")).origin.y) 53 | }) 54 | .onPreferenceChange(ViewOffsetKey.self) { val in 55 | tab.scrollPos = val 56 | } 57 | } 58 | 59 | var body: some View { 60 | HStack { 61 | textView 62 | .textSelection(.enabled) 63 | .searchable(text: $text) 64 | 65 | .onChange(of: text, perform: { newValue in 66 | textRanges = tab.search(text) 67 | }) 68 | .onSubmit(of: .search) { 69 | tab.enterSearch() 70 | } 71 | .frame(minWidth: 200, maxWidth: 800, alignment: .leading) 72 | .padding(.top, 24) 73 | .padding(.bottom, 24) 74 | 75 | 76 | } 77 | 78 | 79 | .frame(minWidth: 200, maxWidth: .infinity, alignment: .center) 80 | } 81 | } 82 | 83 | extension Text { 84 | init(_ string: String, configure: ((inout AttributedString) -> Void)) { 85 | var attributedString = AttributedString(string) /// create an `AttributedString` 86 | configure(&attributedString) /// configure using the closure 87 | self.init(attributedString) /// initialize a `Text` 88 | } 89 | } 90 | 91 | struct ViewOffsetKey: PreferenceKey { 92 | typealias Value = CGFloat 93 | static var defaultValue = CGFloat.zero 94 | static func reduce(value: inout Value, nextValue: () -> Value) { 95 | value += nextValue() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /jimmy/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Bundle name */ 2 | "CFBundleName" = "Jimmy"; 3 | 4 | /* Copyright (human-readable) */ 5 | "NSHumanReadableCopyright" = "Copyright 2022 Jonathan Foucher"; 6 | 7 | -------------------------------------------------------------------------------- /jimmy/es.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Bundle name */ 2 | "CFBundleName" = "Jimmy"; 3 | 4 | /* Copyright (human-readable) */ 5 | "NSHumanReadableCopyright" = "Copyright 2022 Jonathan Foucher"; 6 | 7 | -------------------------------------------------------------------------------- /jimmy/es.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* page not found title. First argument is the error code */ 2 | "%d Page Not Found" = "%d Pagina indisponible"; 3 | 4 | /* Generic server error title. First param is the error code */ 5 | "%d Server Error" = "Error del servidor %d"; 6 | "Expired certificate" = "Certificado expirado"; 7 | /* SSL certificate invalid for this host. first argument is the emoji, the second the host name */ 8 | "😔 The SSL certificate for %@%@ is invalid." = "😔 El certificado SSL de %1$@%2$@ esta invalido"; 9 | "😔 The SSL certificate for %@%@ has expired." = "😔 El certificado SSL de %1$@%2$@ ha expirado"; 10 | /* No comment provided by engineer. */ 11 | "Answer" = "Respuesta"; 12 | 13 | /* No comment provided by engineer. */ 14 | "Bookmarks" = "Favoritos"; 15 | 16 | /* No comment provided by engineer. */ 17 | "Clear history" = "Vaciar historico"; 18 | 19 | /* No comment provided by engineer. */ 20 | "Copy link address" = "Copiar el enlace"; 21 | 22 | /* No comment provided by engineer. */ 23 | "Could not connect" = "Conneccion impossible"; 24 | 25 | /* Generic server error subtitle. First param is full url */ 26 | "Could not load %@" = "No se pudo cargar %@"; 27 | 28 | /* No comment provided by engineer. */ 29 | "example.org" = "example.org"; 30 | 31 | /* Button label to ignore certificate validation for this host */ 32 | "Ignore certificate validation for %@%@" = "Aceptar el certificado de %1$@%2$@"; 33 | 34 | /* No comment provided by engineer. */ 35 | "Invalid certificate" = "Certificado invalido"; 36 | 37 | /* No comment provided by engineer. */ 38 | "New Tab" = "Nueva pestaña"; 39 | 40 | /* No comment provided by engineer. */ 41 | "Open in new tab" = "Abrir en nueva pestaña"; 42 | 43 | /* No comment provided by engineer. */ 44 | "Open Link in New Tab" = "Abrir en nueva pestaña"; 45 | 46 | /* No comment provided by engineer. */ 47 | "Please make sure your internet conection is working properly" = "Asegurese de que su conección funcione correctamente"; 48 | 49 | /* No comment provided by engineer. */ 50 | "Reload" = "Recargar"; 51 | 52 | /* No comment provided by engineer. */ 53 | "Send" = "Enviar"; 54 | 55 | /* Page not found error subtitle. first argument is the path, second the icon, third the host name */ 56 | "Sorry, the page %@ was not found on %@%@" = "Lo sentimos, la pagina %1$@ no se encontró en %2$@%3$@"; 57 | 58 | /* No comment provided by engineer. */ 59 | "Unknown Error" = "Error desconocido"; 60 | 61 | -------------------------------------------------------------------------------- /jimmy/fr.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* page not found title. First argument is the error code */ 2 | "%d Page Not Found" = "%d Page Introuvable"; 3 | 4 | /* Generic server error title. First param is the error code */ 5 | "%d Server Error" = "Erreur Serveur %d"; 6 | "Expired certificate" = "Certificat expiré"; 7 | /* SSL certificate invalid for this host. first argument is the emoji, the second the host name */ 8 | "😔 The SSL certificate for %@%@ is invalid." = "😔 Le certificat SSL de %1$@%2$@ est invalide"; 9 | "😔 The SSL certificate for %@%@ has expired." = "😔 Le certificat SSL de %1$@%2$@ a expiré"; 10 | /* No comment provided by engineer. */ 11 | "Answer" = "Réponse"; 12 | 13 | /* No comment provided by engineer. */ 14 | "Bookmarks" = "Favoris"; 15 | 16 | /* No comment provided by engineer. */ 17 | "Clear history" = "Vider l'historique"; 18 | 19 | /* No comment provided by engineer. */ 20 | "Copy link address" = "Copier le lien"; 21 | 22 | /* No comment provided by engineer. */ 23 | "Could not connect" = "Connexion impossible"; 24 | 25 | /* Generic server error subtitle. First param is full url */ 26 | "Could not load %@" = "Impossible d'ouvrir %@"; 27 | 28 | /* No comment provided by engineer. */ 29 | "example.org" = "example.org"; 30 | 31 | /* Button label to ignore certificate validation for this host */ 32 | "Ignore certificate validation for %@%@" = "Ignorer la validation du certificat pour %1$@%2$@"; 33 | 34 | /* No comment provided by engineer. */ 35 | "Invalid certificate" = "Certificat invalide"; 36 | 37 | /* No comment provided by engineer. */ 38 | "New Tab" = "Nouvel Onglet"; 39 | 40 | /* No comment provided by engineer. */ 41 | "Open in new tab" = "Ouvrir dans un nouvel onglet"; 42 | 43 | /* No comment provided by engineer. */ 44 | "Open Link in New Tab" = "Ouvrir dans un nouvel onglet"; 45 | 46 | /* No comment provided by engineer. */ 47 | "Please make sure your internet conection is working properly" = "Assurez-vous que votre connexion internet fonctionne"; 48 | 49 | /* No comment provided by engineer. */ 50 | "Reload" = "Recharger"; 51 | 52 | /* No comment provided by engineer. */ 53 | "Send" = "Envoyer"; 54 | 55 | /* Page not found error subtitle. first argument is the path, second the icon, third the host name */ 56 | "Sorry, the page %@ was not found on %@%@" = "Désolé, la page %1$@ n'a pas été trouvée sur %2$@%3$@"; 57 | 58 | /* No comment provided by engineer. */ 59 | "Unknown Error" = "Erreur inconnue"; 60 | 61 | -------------------------------------------------------------------------------- /jimmy/jimmy.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /jimmy/jimmyApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // jimmyApp.swift 3 | // jimmy 4 | // 5 | // Created by Jonathan Foucher on 16/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | 12 | @main 13 | struct jimmyApp: App { 14 | let bookmarks = Bookmarks() 15 | let history = History() 16 | let certificates = IgnoredCertificates() 17 | let actions = Actions() 18 | 19 | var body: some Scene { 20 | WindowGroup { 21 | ContentView() 22 | .environmentObject(bookmarks) 23 | .environmentObject(history) 24 | .environmentObject(certificates) 25 | .environmentObject(actions) 26 | .frame(maxWidth: .infinity, minHeight: 200, alignment: .center) 27 | 28 | } 29 | .handlesExternalEvents(matching: ["*"]) 30 | .windowStyle(.titleBar) 31 | 32 | .windowToolbarStyle(.unified(showsTitle: false)) 33 | .commands(content: { 34 | CommandGroup(replacing: .newItem) { 35 | CommandsView().environmentObject(actions) 36 | 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/logo.png -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 37 | 39 | 46 | 52 | 57 | 58 | 63 | 64 | -------------------------------------------------------------------------------- /screenshots/darkmode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/screenshots/darkmode.png -------------------------------------------------------------------------------- /screenshots/lightmode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfoucher/Jimmy/c786841b716ef7ece1094dd2a3f457f125da1e78/screenshots/lightmode.png --------------------------------------------------------------------------------