├── 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 | 
32 | *Light mode*
33 |
34 |
35 | 
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 |
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 |
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 |
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 |
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 |
7 |
8 |
9 | Jimmy
10 | Bundle name
11 |
12 |
13 | Copyright 2022 Jonathan Foucher
14 | Copyright (human-readable)
15 |
16 |
17 |
18 |
19 |
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 |
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
--------------------------------------------------------------------------------