nandi/frqpublic Fork 0
e5adf58
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Let the lightbox save the picture it is showing

A picture in a message was cached and looked at, and that was as far as it
went: keeping one meant knowing where the media cache is and what the hash in
front of the filename means. So the lightbox grows a Save beside its Back.

Where a save lands is the host's business, so it is a seam op named for the
result — `save-to-downloads!` takes a path and the name to file it under and
answers with where it actually went. jolt reads XDG_DOWNLOAD_DIR, then
~/Downloads, then home, and copies with `cp` because the seam's `slurp` is a
string and a PNG through a string is a broken PNG. Flutter asks
`Platform.isAndroid` rather than assuming, the way `write-private-file!`
already has to: the shared Download store on a phone, XDG on the desktop, the
app's own storage where neither is reachable.

Neither side writes over a file that is there — a taken name becomes
`picture-1.png` — and the answer is shown rather than assumed, because a
button that only stops looking pressed has not said where anything went. The
name comes off the URL, not the cache: the hash in the cache name keeps two
`image.png` apart in one directory of ours and means nothing in yours.

The screen is under common/, so libcosmic gets the button too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T09:04:59-07:00 Browse files
e5adf58 parent: ed06c1a
modified common/frq/cells.cljc +7 -1
@@ -137,6 +137,12 @@
137137 ;; so this is a screen of its own rather than a layer over the chat.
138138 (defonce lightbox (atom nil)) ; {:path :url}
139139
140+;; What the lightbox's Save has to say for itself: the path a picture landed
141+;; at, or `:failed`, or nil before anyone asked. Saving is a file appearing
142+;; somewhere off screen, so the one thing the reader needs back is where — a
143+;; button that only stops looking pressed has told them nothing.
144+(defonce saved-to (atom nil))
145+
140146 ;; The room the reader was in when a line in the overview took them somewhere
141147 ;; else, or nil. The strip is the one place in the app that moves you without
142148 ;; you having asked to leave where you were — everything else is a room you
@@ -266,5 +272,5 @@
266272 join-input search broker-token login-url draft editing replying-to
267273 attachment jump-tick show-users? hide-chat-list? overview? at-present?
268274 emoji-group emoji-search highlight jump-to lightbox overview-return
269- reacting window-height window-width reaction-hover hide-join-part?
275+ saved-to reacting window-height window-width reaction-hover hide-join-part?
270276 image-picker profile-viewing profile-hovering profile-tick])
@@ -137,6 +137,12 @@
137 ;; so this is a screen of its own rather than a layer over the chat.137 ;; so this is a screen of its own rather than a layer over the chat.
138 (defonce lightbox (atom nil)) ; {:path :url}138 (defonce lightbox (atom nil)) ; {:path :url}
139 139
140+;; What the lightbox's Save has to say for itself: the path a picture landed
141+;; at, or `:failed`, or nil before anyone asked. Saving is a file appearing
142+;; somewhere off screen, so the one thing the reader needs back is where — a
143+;; button that only stops looking pressed has told them nothing.
144+(defonce saved-to (atom nil))
145+
140 ;; The room the reader was in when a line in the overview took them somewhere146 ;; The room the reader was in when a line in the overview took them somewhere
141 ;; else, or nil. The strip is the one place in the app that moves you without147 ;; else, or nil. The strip is the one place in the app that moves you without
142 ;; you having asked to leave where you were — everything else is a room you148 ;; you having asked to leave where you were — everything else is a room you
@@ -266,5 +272,5 @@
266 join-input search broker-token login-url draft editing replying-to272 join-input search broker-token login-url draft editing replying-to
267 attachment jump-tick show-users? hide-chat-list? overview? at-present?273 attachment jump-tick show-users? hide-chat-list? overview? at-present?
268 emoji-group emoji-search highlight jump-to lightbox overview-return274 emoji-group emoji-search highlight jump-to lightbox overview-return
269- reacting window-height window-width reaction-hover hide-join-part?275+ saved-to reacting window-height window-width reaction-hover hide-join-part?
270 image-picker profile-viewing profile-hovering profile-tick])276 image-picker profile-viewing profile-hovering profile-tick])
modified common/frq/io.cljc +18 -0
@@ -79,6 +79,24 @@
7979 [path s]
8080 (call :spit [path s]))
8181
82+(defn save-to-downloads!
83+ "Put a copy of the file at `path` where this reader keeps the things they
84+ save, under `filename`, and answer with where it landed — or nil.
85+
86+ Named for the result, like the rest of the seam, because \"where downloads
87+ go\" is a different question on each of the three targets this has to answer
88+ on: an XDG directory on a Linux desktop, the shared Download store on
89+ Android, the app's own storage where neither of those is there. The caller
90+ has a picture out of the media cache and wants it kept somewhere a file
91+ manager will find it; which directory that is is the host's business.
92+
93+ The name is a request rather than a promise: a file already there is not
94+ overwritten, so what comes back may be `picture-1.png` for a `picture.png`
95+ that was asked for. Callers show the answer, which is the only honest way to
96+ say where a thing went."
97+ [path filename]
98+ (call :save-to-downloads! [path filename]))
99+
82100 (defn write-private-file!
83101 "`spit`, for a file nobody else may read — mode 600 where that means
84102 something. The broker token goes through this and nothing else does."
@@ -79,6 +79,24 @@
79 [path s]79 [path s]
80 (call :spit [path s]))80 (call :spit [path s]))
81 81
82+(defn save-to-downloads!
83+ "Put a copy of the file at `path` where this reader keeps the things they
84+ save, under `filename`, and answer with where it landed — or nil.
85+
86+ Named for the result, like the rest of the seam, because \"where downloads
87+ go\" is a different question on each of the three targets this has to answer
88+ on: an XDG directory on a Linux desktop, the shared Download store on
89+ Android, the app's own storage where neither of those is there. The caller
90+ has a picture out of the media cache and wants it kept somewhere a file
91+ manager will find it; which directory that is is the host's business.
92+
93+ The name is a request rather than a promise: a file already there is not
94+ overwritten, so what comes back may be `picture-1.png` for a `picture.png`
95+ that was asked for. Callers show the answer, which is the only honest way to
96+ say where a thing went."
97+ [path filename]
98+ (call :save-to-downloads! [path filename]))
99+
82 (defn write-private-file!100 (defn write-private-file!
83 "`spit`, for a file nobody else may read — mode 600 where that means101 "`spit`, for a file nobody else may read — mode 600 where that means
84 something. The broker token goes through this and nothing else does."102 something. The broker token goes through this and nothing else does."
modified common/frq/screens/app.cljc +35 -3
@@ -14,6 +14,7 @@
1414 (:require [clojure.string :as str]
1515 [frq.actions :as actions]
1616 [frq.cells :as cells]
17+ [frq.io :as io]
1718 [frq.metrics :refer [terminal?]]
1819 [frq.rooms :as rooms]
1920 [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog
@@ -83,6 +84,32 @@
8384 ;; row so the panel is only ever on screen if the message list is given a
8485 ;; width that stops short of it.
8586
87+(defn- close-lightbox!
88+ "Shut the lightbox, and take any note of a save with it: where the last
89+ picture went is not news about the next one."
90+ []
91+ (reset! cells/saved-to nil)
92+ (reset! cells/lightbox nil))
93+
94+(defn- save-name
95+ "What to file a saved picture under: the last segment of the URL it came
96+ from, which is the name the person who posted it gave it, and a plain one
97+ where the URL has nothing usable on the end.
98+
99+ Not the cache name that carries the hash `frq.media.core` puts in front to
100+ keep two `image.png` apart, which is a fact about our cache directory and
101+ means nothing in a downloads folder."
102+ [url]
103+ (let [tail (-> (str url) (str/split #"[?#]") first (str/split #"/") last)]
104+ (if (re-find #"^[A-Za-z0-9._-]+\.[A-Za-z0-9]+$" (str tail)) tail "picture.png")))
105+
106+(defn- save-picture!
107+ "Copy the picture being looked at to where this host keeps saved files, and
108+ say where it went. The host decides which directory that is; see
109+ `frq.io/save-to-downloads!`."
110+ [path url]
111+ (reset! cells/saved-to (or (io/save-to-downloads! path (save-name url)) :failed)))
112+
86113 (defn lightbox-screen
87114 "One picture, as big as the window will paint it.
88115
@@ -95,11 +122,16 @@
95122 gesture that opened it but a way out you have to guess at is not one, and
96123 the row costs the picture a line."
97124 []
98- (let [{:keys [path]} @cells/lightbox]
125+ (let [{:keys [path url]} @cells/lightbox
126+ saved @cells/saved-to]
99127 [:vbox {:spacing 4 :margin 4}
100128 [:hbox {:spacing 8}
101- [:button {:label "← Back" :on-click #(reset! cells/lightbox nil)}]]
102- [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]]))
129+ [:button {:label "← Back" :on-click #(close-lightbox!)}]
130+ [:button {:label "Save" :on-click #(save-picture! path url)}]
131+ (cond
132+ (= :failed saved) [:dim-label {:label "Could not save that picture."}]
133+ (string? saved) [:dim-label {:label (str "Saved to " saved)}])]
134+ [:image {:src path :fit true :on-click #(close-lightbox!)}]]))
103135
104136 (defn- profile-dialog
105137 "Who someone is, as a dialog: centred over the window, with what you were
@@ -14,6 +14,7 @@
14 (:require [clojure.string :as str]14 (:require [clojure.string :as str]
15 [frq.actions :as actions]15 [frq.actions :as actions]
16 [frq.cells :as cells]16 [frq.cells :as cells]
17+ [frq.io :as io]
17 [frq.metrics :refer [terminal?]]18 [frq.metrics :refer [terminal?]]
18 [frq.rooms :as rooms]19 [frq.rooms :as rooms]
19 [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog20 [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog
@@ -83,6 +84,32 @@
83 ;; row so the panel is only ever on screen if the message list is given a84 ;; row so the panel is only ever on screen if the message list is given a
84 ;; width that stops short of it.85 ;; width that stops short of it.
85 86
87+(defn- close-lightbox!
88+ "Shut the lightbox, and take any note of a save with it: where the last
89+ picture went is not news about the next one."
90+ []
91+ (reset! cells/saved-to nil)
92+ (reset! cells/lightbox nil))
93+
94+(defn- save-name
95+ "What to file a saved picture under: the last segment of the URL it came
96+ from, which is the name the person who posted it gave it, and a plain one
97+ where the URL has nothing usable on the end.
98+
99+ Not the cache name that carries the hash `frq.media.core` puts in front to
100+ keep two `image.png` apart, which is a fact about our cache directory and
101+ means nothing in a downloads folder."
102+ [url]
103+ (let [tail (-> (str url) (str/split #"[?#]") first (str/split #"/") last)]
104+ (if (re-find #"^[A-Za-z0-9._-]+\.[A-Za-z0-9]+$" (str tail)) tail "picture.png")))
105+
106+(defn- save-picture!
107+ "Copy the picture being looked at to where this host keeps saved files, and
108+ say where it went. The host decides which directory that is; see
109+ `frq.io/save-to-downloads!`."
110+ [path url]
111+ (reset! cells/saved-to (or (io/save-to-downloads! path (save-name url)) :failed)))
112+
86 (defn lightbox-screen113 (defn lightbox-screen
87 "One picture, as big as the window will paint it.114 "One picture, as big as the window will paint it.
88 115
@@ -95,11 +122,16 @@
95 gesture that opened it but a way out you have to guess at is not one, and122 gesture that opened it but a way out you have to guess at is not one, and
96 the row costs the picture a line."123 the row costs the picture a line."
97 []124 []
98- (let [{:keys [path]} @cells/lightbox]125+ (let [{:keys [path url]} @cells/lightbox
126+ saved @cells/saved-to]
99 [:vbox {:spacing 4 :margin 4}127 [:vbox {:spacing 4 :margin 4}
100 [:hbox {:spacing 8}128 [:hbox {:spacing 8}
101- [:button {:label "← Back" :on-click #(reset! cells/lightbox nil)}]]129+ [:button {:label "← Back" :on-click #(close-lightbox!)}]
102- [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]]))130+ [:button {:label "Save" :on-click #(save-picture! path url)}]
131+ (cond
132+ (= :failed saved) [:dim-label {:label "Could not save that picture."}]
133+ (string? saved) [:dim-label {:label (str "Saved to " saved)}])]
134+ [:image {:src path :fit true :on-click #(close-lightbox!)}]]))
103 135
104 (defn- profile-dialog136 (defn- profile-dialog
105 "Who someone is, as a dialog: centred over the window, with what you were137 "Who someone is, as a dialog: centred over the window, with what you were
modified flutter/src/frq/io/dart.cljd +57 -0
@@ -67,6 +67,62 @@
6767 true)
6868 (catch Exception _ false))))
6969
70+(defn- dir-at
71+ "`p` if there is a directory there, else nil."
72+ [p]
73+ (when (and (seq (str p)) (.existsSync (io/Directory. p))) p))
74+
75+(defn- downloads-dir
76+ "Where a save lands, asked rather than assumed — this file compiles for a
77+ Linux desktop and for Android, and they do not agree.
78+
79+ The desktop has XDG: `XDG_DOWNLOAD_DIR` where the user-dirs file names one,
80+ otherwise `~/Downloads`, created if that is all that is missing. Android has
81+ the shared `Download` store, which is writable without a permission on the
82+ devices that still let an app see it at all, and where it is not, the app's
83+ own storage is the honest answer it is somewhere, and the caller shows
84+ where."
85+ [fallback]
86+ (if (.-isAndroid io/Platform)
87+ (or (dir-at "/storage/emulated/0/Download")
88+ (dir-at "/sdcard/Download")
89+ fallback)
90+ (let [env (.-environment io/Platform)
91+ xdg (get env "XDG_DOWNLOAD_DIR")
92+ home (get env "HOME")
93+ dir (cond (seq (str xdg)) (str xdg)
94+ (seq (str home)) (str home "/Downloads")
95+ :else nil)]
96+ (when dir
97+ (when-not (.existsSync (io/Directory. dir))
98+ (try (.createSync (io/Directory. dir) .recursive true) (catch Exception _ nil))))
99+ (or (dir-at dir) (dir-at (get env "HOME")) fallback))))
100+
101+(defn- free-path
102+ "`dir/name`, or the same with `-1`, `-2`… before the extension until it
103+ names a file that is not there. A save never writes over one."
104+ [dir name]
105+ (let [m (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name)
106+ stem (or (second m) name)
107+ ext (or (nth m 2 nil) "")]
108+ (loop [n 0]
109+ (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)]
110+ (cond
111+ (not (.existsSync (io/File. p))) p
112+ (> n 99) nil
113+ :else (recur (inc n)))))))
114+
115+(defn- save-to-downloads!
116+ "`copySync` and not a read-then-write: what is copied is a PNG, and the
117+ seam's `spit` takes a string."
118+ [fallback path filename]
119+ (try
120+ (when-let [dir (downloads-dir fallback)]
121+ (when-let [dest (free-path dir (or filename "picture.png"))]
122+ (.copySync (io/File. path) dest)
123+ (when (.existsSync (io/File. dest)) dest)))
124+ (catch Exception _ nil)))
125+
70126 (defn- local-offset-seconds
71127 "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop.
72128 The offset is per-instant, which is what the caller wants."
@@ -115,6 +171,7 @@
115171 :slurp slurp*
116172 :spit spit*
117173 :write-private-file! write-private-file!
174+ :save-to-downloads! (fn [p f] (save-to-downloads! dir p f))
118175 :utf8-bytes (fn [s] (vec (.encode conv/utf8 (str s))))
119176 :utf8-string utf8-string
120177 :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now))))
@@ -67,6 +67,62 @@
67 true)67 true)
68 (catch Exception _ false))))68 (catch Exception _ false))))
69 69
70+(defn- dir-at
71+ "`p` if there is a directory there, else nil."
72+ [p]
73+ (when (and (seq (str p)) (.existsSync (io/Directory. p))) p))
74+
75+(defn- downloads-dir
76+ "Where a save lands, asked rather than assumed — this file compiles for a
77+ Linux desktop and for Android, and they do not agree.
78+
79+ The desktop has XDG: `XDG_DOWNLOAD_DIR` where the user-dirs file names one,
80+ otherwise `~/Downloads`, created if that is all that is missing. Android has
81+ the shared `Download` store, which is writable without a permission on the
82+ devices that still let an app see it at all, and where it is not, the app's
83+ own storage is the honest answer it is somewhere, and the caller shows
84+ where."
85+ [fallback]
86+ (if (.-isAndroid io/Platform)
87+ (or (dir-at "/storage/emulated/0/Download")
88+ (dir-at "/sdcard/Download")
89+ fallback)
90+ (let [env (.-environment io/Platform)
91+ xdg (get env "XDG_DOWNLOAD_DIR")
92+ home (get env "HOME")
93+ dir (cond (seq (str xdg)) (str xdg)
94+ (seq (str home)) (str home "/Downloads")
95+ :else nil)]
96+ (when dir
97+ (when-not (.existsSync (io/Directory. dir))
98+ (try (.createSync (io/Directory. dir) .recursive true) (catch Exception _ nil))))
99+ (or (dir-at dir) (dir-at (get env "HOME")) fallback))))
100+
101+(defn- free-path
102+ "`dir/name`, or the same with `-1`, `-2`… before the extension until it
103+ names a file that is not there. A save never writes over one."
104+ [dir name]
105+ (let [m (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name)
106+ stem (or (second m) name)
107+ ext (or (nth m 2 nil) "")]
108+ (loop [n 0]
109+ (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)]
110+ (cond
111+ (not (.existsSync (io/File. p))) p
112+ (> n 99) nil
113+ :else (recur (inc n)))))))
114+
115+(defn- save-to-downloads!
116+ "`copySync` and not a read-then-write: what is copied is a PNG, and the
117+ seam's `spit` takes a string."
118+ [fallback path filename]
119+ (try
120+ (when-let [dir (downloads-dir fallback)]
121+ (when-let [dest (free-path dir (or filename "picture.png"))]
122+ (.copySync (io/File. path) dest)
123+ (when (.existsSync (io/File. dest)) dest)))
124+ (catch Exception _ nil)))
125+
70 (defn- local-offset-seconds126 (defn- local-offset-seconds
71 "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop.127 "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop.
72 The offset is per-instant, which is what the caller wants."128 The offset is per-instant, which is what the caller wants."
@@ -115,6 +171,7 @@
115 :slurp slurp*171 :slurp slurp*
116 :spit spit*172 :spit spit*
117 :write-private-file! write-private-file!173 :write-private-file! write-private-file!
174+ :save-to-downloads! (fn [p f] (save-to-downloads! dir p f))
118 :utf8-bytes (fn [s] (vec (.encode conv/utf8 (str s))))175 :utf8-bytes (fn [s] (vec (.encode conv/utf8 (str s))))
119 :utf8-string utf8-string176 :utf8-string utf8-string
120 :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now))))177 :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now))))
modified src/frq/io/jolt.clj +39 -0
@@ -52,6 +52,44 @@
5252 (defn- spit* [path s]
5353 (try (spit path s) true (catch Exception _ false)))
5454
55+(defn- downloads-dir
56+ "Where this desktop keeps saved files: XDG's answer if the user-dirs file
57+ has one, otherwise ~/Downloads, otherwise home. Created if it is only
58+ missing — a reader with no ~/Downloads still means \"save it\"."
59+ []
60+ (let [home (host/getenv "HOME")
61+ xdg (let [v (host/getenv "XDG_DOWNLOAD_DIR")] (when (seq v) v))
62+ dir (or xdg (when (seq home) (str home "/Downloads")) home)]
63+ (when (seq dir)
64+ (when-not (host/directory? dir) (host/mkdirs! dir))
65+ (when (host/directory? dir) dir))))
66+
67+(defn- free-path
68+ "`dir/name`, or the same with `-1`, `-2`… before the extension until it
69+ names nothing. Nothing saved is ever written over."
70+ [dir name]
71+ (let [[_ stem ext] (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name)
72+ stem (or stem name)
73+ ext (or ext "")]
74+ (loop [n 0]
75+ (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)]
76+ (cond
77+ (not (host/file-exists? p)) p
78+ (> n 99) nil
79+ :else (recur (inc n)))))))
80+
81+(defn- save-to-downloads!
82+ "`cp` and not a read-then-write: what is being copied is a picture, the
83+ seam's `slurp` is a string, and bytes through a string is how a PNG comes
84+ out the other side broken."
85+ [path filename]
86+ (try
87+ (when-let [dir (downloads-dir)]
88+ (when-let [dest (free-path dir (or filename "picture.png"))]
89+ (host/sh (str "cp '" path "' '" dest "'"))
90+ (when (host/file-exists? dest) dest)))
91+ (catch Exception _ nil)))
92+
5593 (defn- write-private-file!
5694 "Created before it is written, so the token is never on disk world-readable
5795 even for an instant."
@@ -75,6 +113,7 @@
75113 :slurp slurp*
76114 :spit spit*
77115 :write-private-file! write-private-file!
116+ :save-to-downloads! save-to-downloads!
78117 :utf8-bytes (fn [s] (mapv #(bit-and (int %) 0xff) (.getBytes (str s))))
79118 :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs))))
80119 :wall-nanos host/wall-nanos
@@ -52,6 +52,44 @@
52 (defn- spit* [path s]52 (defn- spit* [path s]
53 (try (spit path s) true (catch Exception _ false)))53 (try (spit path s) true (catch Exception _ false)))
54 54
55+(defn- downloads-dir
56+ "Where this desktop keeps saved files: XDG's answer if the user-dirs file
57+ has one, otherwise ~/Downloads, otherwise home. Created if it is only
58+ missing — a reader with no ~/Downloads still means \"save it\"."
59+ []
60+ (let [home (host/getenv "HOME")
61+ xdg (let [v (host/getenv "XDG_DOWNLOAD_DIR")] (when (seq v) v))
62+ dir (or xdg (when (seq home) (str home "/Downloads")) home)]
63+ (when (seq dir)
64+ (when-not (host/directory? dir) (host/mkdirs! dir))
65+ (when (host/directory? dir) dir))))
66+
67+(defn- free-path
68+ "`dir/name`, or the same with `-1`, `-2`… before the extension until it
69+ names nothing. Nothing saved is ever written over."
70+ [dir name]
71+ (let [[_ stem ext] (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name)
72+ stem (or stem name)
73+ ext (or ext "")]
74+ (loop [n 0]
75+ (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)]
76+ (cond
77+ (not (host/file-exists? p)) p
78+ (> n 99) nil
79+ :else (recur (inc n)))))))
80+
81+(defn- save-to-downloads!
82+ "`cp` and not a read-then-write: what is being copied is a picture, the
83+ seam's `slurp` is a string, and bytes through a string is how a PNG comes
84+ out the other side broken."
85+ [path filename]
86+ (try
87+ (when-let [dir (downloads-dir)]
88+ (when-let [dest (free-path dir (or filename "picture.png"))]
89+ (host/sh (str "cp '" path "' '" dest "'"))
90+ (when (host/file-exists? dest) dest)))
91+ (catch Exception _ nil)))
92+
55 (defn- write-private-file!93 (defn- write-private-file!
56 "Created before it is written, so the token is never on disk world-readable94 "Created before it is written, so the token is never on disk world-readable
57 even for an instant."95 even for an instant."
@@ -75,6 +113,7 @@
75 :slurp slurp*113 :slurp slurp*
76 :spit spit*114 :spit spit*
77 :write-private-file! write-private-file!115 :write-private-file! write-private-file!
116+ :save-to-downloads! save-to-downloads!
78 :utf8-bytes (fn [s] (mapv #(bit-and (int %) 0xff) (.getBytes (str s))))117 :utf8-bytes (fn [s] (mapv #(bit-and (int %) 0xff) (.getBytes (str s))))
79 :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs))))118 :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs))))
80 :wall-nanos host/wall-nanos119 :wall-nanos host/wall-nanos