fileHelpers.mjs (11069B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const { AppConstants } = ChromeUtils.importESModule( 6 "resource://gre/modules/AppConstants.sys.mjs" 7 ); 8 const { XPCOMUtils } = ChromeUtils.importESModule( 9 "resource://gre/modules/XPCOMUtils.sys.mjs" 10 ); 11 12 const lazy = {}; 13 // Windows has a total path length of 259 characters so we have to calculate 14 // the max filename length by 15 // MAX_PATH_LENGTH_WINDOWS - downloadDir length - null terminator character 16 // in the function getMaxFilenameLength below. 17 export const MAX_PATH_LENGTH_WINDOWS = 259; 18 // Windows allows 255 character filenames in the filepicker 19 // macOS has a max filename length of 255 characters 20 // Linux has a max filename length of 255 bytes 21 export const MAX_FILENAME_LENGTH = 255; 22 23 ChromeUtils.defineESModuleGetters(lazy, { 24 Downloads: "resource://gre/modules/Downloads.sys.mjs", 25 DownloadLastDir: "resource://gre/modules/DownloadLastDir.sys.mjs", 26 DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", 27 FileUtils: "resource://gre/modules/FileUtils.sys.mjs", 28 ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs", 29 }); 30 31 XPCOMUtils.defineLazyPreferenceGetter( 32 lazy, 33 "useDownloadDir", 34 "browser.download.useDownloadDir", 35 true 36 ); 37 38 /** 39 * macOS and Linux have a max filename of 255. 40 * Windows allows 259 as the total path length so we have to calculate the max 41 * filename length if the download directory exists. Otherwise, Windows allows 42 * 255 character filenames in the filepicker. 43 * 44 * @param {string} downloadDir The current download directory or null 45 * @returns {number} The max filename length 46 */ 47 export function getMaxFilenameLength(downloadDir = null) { 48 if (!downloadDir || AppConstants.platform !== "win") { 49 return MAX_FILENAME_LENGTH; 50 } 51 52 return MAX_PATH_LENGTH_WINDOWS - downloadDir.length - 1; 53 } 54 55 /** 56 * Linux has a max length of bytes while macOS and Windows has a max length of 57 * characters so we have to check them differently. 58 * 59 * @param {string} filename The current clipped filename 60 * @param {string} maxFilenameLength The max length of the filename 61 * @returns {boolean} True if the filename is too long, otherwise false 62 */ 63 function checkFilenameLength(filename, maxFilenameLength) { 64 if (AppConstants.platform === "linux") { 65 return new Blob([filename]).size > maxFilenameLength; 66 } 67 68 return filename.length > maxFilenameLength; 69 } 70 71 /** 72 * Gets the filename automatically or by a file picker depending on "browser.download.useDownloadDir" 73 * 74 * @param filenameTitle The title of the current page 75 * @param browser The current browser 76 * @returns Path of the chosen filename 77 */ 78 export async function getFilename(filenameTitle, browser) { 79 if (filenameTitle === null) { 80 filenameTitle = await lazy.ScreenshotsUtils.getActor(browser).sendQuery( 81 "Screenshots:getDocumentTitle" 82 ); 83 } 84 const date = new Date(); 85 const knownDownloadsDir = await getDownloadDirectory(); 86 // if we know the download directory, we can subtract that plus the separator from MAX_PATHNAME to get a length limit 87 // otherwise we just use a conservative length 88 const maxFilenameLength = getMaxFilenameLength(knownDownloadsDir); 89 /* eslint-disable no-control-regex */ 90 filenameTitle = filenameTitle 91 .replace(/[\\/]/g, "_") 92 .replace(/[\u200e\u200f\u202a-\u202e]/g, "") 93 .replace(/[\x00-\x1f\x7f-\x9f:*?|"<>;,+=\[\]]+/g, " ") 94 .replace(/^[\s\u180e.]+|[\s\u180e.]+$/g, ""); 95 /* eslint-enable no-control-regex */ 96 filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " "); 97 const currentDateTime = new Date( 98 date.getTime() - date.getTimezoneOffset() * 60 * 1000 99 ).toISOString(); 100 const filenameDate = currentDateTime.substring(0, 10); 101 const filenameTime = currentDateTime.substring(11, 19).replace(/:/g, "-"); 102 let clipFilename = `Screenshot ${filenameDate} at ${filenameTime} ${filenameTitle}`; 103 104 // allow space for a potential ellipsis and the extension 105 let maxNameStemLength = maxFilenameLength - "[...].png".length; 106 107 // Crop the filename size so as to leave 108 // room for the extension and an ellipsis [...]. Note that JS 109 // strings are UTF16 but the filename will be converted to UTF8 110 // when saving which could take up more space, and we want a 111 // maximum of maxFilenameLength bytes (not characters). Here, we iterate 112 // and crop at shorter and shorter points until we fit into 113 // our max number of bytes. 114 let suffix = ""; 115 for (let cropSize = maxNameStemLength; cropSize >= 0; cropSize -= 1) { 116 if (checkFilenameLength(clipFilename, maxNameStemLength)) { 117 clipFilename = clipFilename.substring(0, cropSize); 118 suffix = "[...]"; 119 } else { 120 break; 121 } 122 } 123 clipFilename += suffix; 124 125 let extension = ".png"; 126 let filename = clipFilename + extension; 127 128 if (knownDownloadsDir) { 129 // If filename is absolute, it will override the downloads directory and 130 // still be applied as expected. 131 filename = PathUtils.join(knownDownloadsDir, filename); 132 } else { 133 let fileInfo = new FileInfo(filename); 134 let file; 135 let fpParams = { 136 fpTitleKey: "SaveImageTitle", 137 fileInfo, 138 contentType: "image/png", 139 saveAsType: 0, 140 file, 141 }; 142 let accepted = await promiseTargetFile(fpParams, browser.ownerGlobal); 143 if (!accepted) { 144 return { filename: null, accepted }; 145 } 146 filename = fpParams.file.path; 147 } 148 return { filename, accepted: true }; 149 } 150 151 /** 152 * Gets the path to the preferred screenshots directory if "browser.download.useDownloadDir" is true 153 * 154 * @returns Path to preferred screenshots directory or null if not available 155 */ 156 export async function getDownloadDirectory() { 157 if (lazy.useDownloadDir) { 158 const downloadsDir = 159 await lazy.Downloads.getPreferredScreenshotsDirectory(); 160 if (await IOUtils.exists(downloadsDir)) { 161 return downloadsDir; 162 } 163 } 164 return null; 165 } 166 167 // The below functions are a modified copy from toolkit/content/contentAreaUtils.js 168 /** 169 * Structure for holding info about a URL and the target filename it should be 170 * saved to. 171 * 172 * @param aFileName The target filename 173 */ 174 class FileInfo { 175 constructor(aFileName) { 176 this.fileName = aFileName; 177 this.fileBaseName = aFileName.replace(".png", ""); 178 this.fileExt = "png"; 179 } 180 } 181 182 const ContentAreaUtils = { 183 get stringBundle() { 184 delete this.stringBundle; 185 return (this.stringBundle = Services.strings.createBundle( 186 "chrome://global/locale/contentAreaCommands.properties" 187 )); 188 }, 189 }; 190 191 function makeFilePicker() { 192 const fpContractID = "@mozilla.org/filepicker;1"; 193 const fpIID = Ci.nsIFilePicker; 194 return Cc[fpContractID].createInstance(fpIID); 195 } 196 197 function getMIMEService() { 198 const mimeSvcContractID = "@mozilla.org/mime;1"; 199 const mimeSvcIID = Ci.nsIMIMEService; 200 const mimeSvc = Cc[mimeSvcContractID].getService(mimeSvcIID); 201 return mimeSvc; 202 } 203 204 function getMIMEInfoForType(aMIMEType, aExtension) { 205 if (aMIMEType || aExtension) { 206 try { 207 return getMIMEService().getFromTypeAndExtension(aMIMEType, aExtension); 208 } catch (e) {} 209 } 210 return null; 211 } 212 213 // This is only used after the user has entered a filename. 214 function validateFileName(aFileName) { 215 let processed = 216 lazy.DownloadPaths.sanitize(aFileName, { 217 compressWhitespaces: false, 218 allowInvalidFilenames: true, 219 }) || "_"; 220 if (AppConstants.platform == "android") { 221 // If a large part of the filename has been sanitized, then we 222 // will use a default filename instead 223 if (processed.replace(/_/g, "").length <= processed.length / 2) { 224 // We purposefully do not use a localized default filename, 225 // which we could have done using 226 // ContentAreaUtils.stringBundle.GetStringFromName("UntitledSaveFileName") 227 // since it may contain invalid characters. 228 let original = processed; 229 processed = "download"; 230 231 // Preserve a suffix, if there is one 232 if (original.includes(".")) { 233 let suffix = original.split(".").pop(); 234 if (suffix && !suffix.includes("_")) { 235 processed += "." + suffix; 236 } 237 } 238 } 239 } 240 return processed; 241 } 242 243 function appendFiltersForContentType( 244 aFilePicker, 245 aContentType, 246 aFileExtension 247 ) { 248 let mimeInfo = getMIMEInfoForType(aContentType, aFileExtension); 249 if (mimeInfo) { 250 let extString = ""; 251 for (let extension of mimeInfo.getFileExtensions()) { 252 if (extString) { 253 extString += "; "; 254 } // If adding more than one extension, 255 // separate by semi-colon 256 extString += "*." + extension; 257 } 258 259 if (extString) { 260 aFilePicker.appendFilter(mimeInfo.description, extString); 261 } 262 } 263 264 // Always append the all files (*) filter 265 aFilePicker.appendFilters(Ci.nsIFilePicker.filterAll); 266 } 267 268 /** 269 * Given the Filepicker Parameters (aFpP), show the file picker dialog, 270 * prompting the user to confirm (or change) the fileName. 271 * 272 * @param aFpP 273 * A structure (see definition in internalSave(...) method) 274 * containing all the data used within this method. 275 * @param win 276 * The window used for opening the file picker 277 * @returns {Promise<boolean>} 278 * Resolves when the dialog is closed. When resolved to true, it indicates that 279 * the file picker dialog is accepted. 280 */ 281 function promiseTargetFile(aFpP, win) { 282 return (async function () { 283 let downloadLastDir = new lazy.DownloadLastDir(win); 284 285 // Default to the user's default downloads directory configured 286 // through download prefs. 287 let dirPath = await lazy.Downloads.getPreferredDownloadsDirectory(); 288 let dirExists = await IOUtils.exists(dirPath); 289 let dir = new lazy.FileUtils.File(dirPath); 290 291 // We must prompt for the file name explicitly. 292 // If we must prompt because we were asked to... 293 let file = await downloadLastDir.getFileAsync(null); 294 if (file && (await IOUtils.exists(file.path))) { 295 dir = file; 296 dirExists = true; 297 } 298 299 if (!dirExists) { 300 // Default to desktop. 301 dir = Services.dirsvc.get("Desk", Ci.nsIFile); 302 } 303 304 let fp = makeFilePicker(); 305 let titleKey = aFpP.fpTitleKey; 306 fp.init( 307 win.browsingContext, 308 ContentAreaUtils.stringBundle.GetStringFromName(titleKey), 309 Ci.nsIFilePicker.modeSave 310 ); 311 312 fp.displayDirectory = dir; 313 fp.defaultExtension = aFpP.fileInfo.fileExt; 314 fp.defaultString = aFpP.fileInfo.fileName; 315 appendFiltersForContentType(fp, aFpP.contentType, aFpP.fileInfo.fileExt); 316 317 let result = await new Promise(resolve => { 318 fp.open(function (aResult) { 319 resolve(aResult); 320 }); 321 }); 322 if (result == Ci.nsIFilePicker.returnCancel || !fp.file) { 323 return false; 324 } 325 326 // Do not store the last save directory as a pref inside the private browsing mode 327 downloadLastDir.setFile(null, fp.file.parent); 328 329 aFpP.saveAsType = fp.filterIndex; 330 aFpP.file = fp.file; 331 aFpP.file.leafName = validateFileName(aFpP.file.leafName); 332 333 return true; 334 })(); 335 }