├── .gitignore ├── assets ├── ios.png ├── back.png ├── folder.png ├── shape.png ├── example@3x.png ├── ic_launcher.png ├── Contents.json └── fileicon ├── tools ├── image.js └── file.js ├── package.json ├── LICENSE ├── index_old.js ├── index.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # fmaker folder icon 3 | Icon? 4 | -------------------------------------------------------------------------------- /assets/ios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjl0602/flutter-assets-maker/HEAD/assets/ios.png -------------------------------------------------------------------------------- /assets/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjl0602/flutter-assets-maker/HEAD/assets/back.png -------------------------------------------------------------------------------- /assets/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjl0602/flutter-assets-maker/HEAD/assets/folder.png -------------------------------------------------------------------------------- /assets/shape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjl0602/flutter-assets-maker/HEAD/assets/shape.png -------------------------------------------------------------------------------- /assets/example@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjl0602/flutter-assets-maker/HEAD/assets/example@3x.png -------------------------------------------------------------------------------- /assets/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjl0602/flutter-assets-maker/HEAD/assets/ic_launcher.png -------------------------------------------------------------------------------- /tools/image.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | let path = require("path"); 3 | module.exports = { 4 | resizeAndSave, 5 | deltaOf, 6 | }; 7 | 8 | function resizeAndSave(image, size, fileName) { 9 | // console.log("resizeAndSave", fileName); 10 | var targetPath = fileName.split("/"); 11 | targetPath.pop(); 12 | targetPath = targetPath.join("/"); 13 | fs.mkdirSync(targetPath, { 14 | recursive: true, 15 | }); 16 | return new Promise((r, e) => { 17 | image.resize(size).toFile(fileName, (err, info) => { 18 | err ? e(err) : r(info); 19 | }); 20 | }); 21 | } 22 | 23 | // 从文件名获取倍率 24 | function deltaOf(name) { 25 | let result = name.match(/@(\S*)[Xx]/) || []; 26 | if (result.length <= 1) { 27 | return 0; 28 | } 29 | result = parseInt(result[1]); 30 | return result || 0; 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flutter-assets-maker", 3 | "version": "1.2.5", 4 | "description": "Auto creat flutter assets", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "bin": { 10 | "fmaker": "./index.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/mjl0602/flutter-assets-maker.git" 15 | }, 16 | "keywords": [ 17 | "flutter", 18 | "assets", 19 | "node", 20 | "images" 21 | ], 22 | "author": "mjl", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/mjl0602/flutter-assets-maker/issues" 26 | }, 27 | "homepage": "https://github.com/mjl0602/flutter-assets-maker#readme", 28 | "dependencies": { 29 | "commander": "^7.2.0", 30 | "sharp": "^0.30.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 mjl0602 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index_old.js: -------------------------------------------------------------------------------- 1 | // #!/usr/bin/env node 2 | // const sharp = require("sharp"); 3 | 4 | // const fs = require("fs"); 5 | // const join = require("path").join; 6 | // const path = require("path"); 7 | 8 | // // const { file, resolve, find, savefile, mkdir, exists } = require("../tools/file"); 9 | 10 | // const { makeios, makeAndroid } = require("./builder/ios"); 11 | // const { initFlutter, makeFolder, makeflutter, make } = require("./builder/flutter"); 12 | 13 | // /// 当前执行命令的路径 14 | // let execPath = process.cwd(); 15 | 16 | // main(process.argv); 17 | 18 | // async function main(args) { 19 | // console.log("args", args); 20 | // if (args[2] == "init") { 21 | // console.log("为你添加一些示例图片"); 22 | // initFlutter(); 23 | // return; 24 | // } else if (args[2] == "make") { 25 | // console.log("正在通过指定文件创建低倍图"); 26 | // make(args[3]); 27 | // return; 28 | // } else if (args[2] == "ios") { 29 | // console.log("单独创建iOS图标"); 30 | // makeios(args[3]); 31 | // return; 32 | // } else if (args[2] == "android") { 33 | // console.log("单独创建安卓图标"); 34 | // makeAndroid(args[3]); 35 | // return; 36 | // } else if (args[2] == "build") { 37 | // console.log("创建flutter资源"); 38 | // await makeflutter(); 39 | // console.log("\nflutter资源全部创建完成\n"); 40 | // return; 41 | // } else if (args[2] == "folder") { 42 | // console.log("添加项目文件夹图标"); 43 | // await makeFolder(); 44 | // console.log("\n设置项目文件夹图标完成\n"); 45 | // return; 46 | // } 47 | // console.log("没有对应指令,fmaker已安装"); 48 | // console.log(args[2]); 49 | // } 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const exec = require("child_process").exec; 4 | const fs = require("fs"); 5 | const { program } = require("commander"); 6 | const { join } = require("path"); 7 | 8 | const { makeios, makeAndroid } = require("./builder/ios"); 9 | const { makeScreenshot } = require("./builder/screenshot"); 10 | const { 11 | initFlutter, 12 | makeFolder, 13 | makeflutter, 14 | makePreview, 15 | make, 16 | } = require("./builder/flutter"); 17 | 18 | /** 初始化项目 */ 19 | const init = program.command("init"); 20 | init 21 | .description( 22 | "在一个Flutter项目中初始化tmaker,为你创建文件夹,添加示例文件和添加.gitignore参数" 23 | ) 24 | .action(async (_, __) => { 25 | console.log("为你添加一些示例图片"); 26 | initFlutter(); 27 | }); 28 | 29 | /** 创建项目 */ 30 | program 31 | .command("build [parts]") 32 | .description( 33 | "创建资源,可指定创建指定部分,例: fmaker build ios,android,assets" 34 | ) 35 | .action(async (parts, __) => { 36 | console.log("创建flutter资源", parts); 37 | var partList = (parts || "").split(",").filter((e) => !!e); 38 | if (partList.length == 0) partList = ["ios", "android", "assets"]; 39 | await makeflutter(process.cwd(), { 40 | ios: !!~partList.indexOf("ios"), 41 | android: !!~partList.indexOf("android"), 42 | assets: !!~partList.indexOf("assets"), 43 | }); 44 | console.log("\nflutter资源全部创建完成\n"); 45 | }); 46 | 47 | /** 仅创建图片预览 */ 48 | program 49 | .command("preview") 50 | .description("仅创建资源的预览注释,也就是r.preview.dart文件") 51 | .action(async (_, __) => { 52 | console.log("创建注释中"); 53 | await makePreview(process.cwd()); 54 | console.log("\n注释全部创建完成\n"); 55 | }); 56 | 57 | /** 项目文件夹图标 */ 58 | const folder = program.command("folder"); 59 | folder 60 | .description("把app的图标渲染在本项目的文件夹上(仅mac)") 61 | .action(async (_, __) => { 62 | console.log("添加项目文件夹图标"); 63 | await makeFolder(); 64 | console.log("\n设置项目文件夹图标完成\n"); 65 | }); 66 | // program.addCommand(folder); 67 | /** 项目文件夹图标 */ 68 | const screenshot = program.command("screenshot"); 69 | screenshot 70 | .description("处理当前文件夹下的截图,处理成Apple的标准尺寸") 71 | .action(async (_, __) => { 72 | console.log("开始处理截图"); 73 | await makeScreenshot(); 74 | console.log("\n处理截图完成\n"); 75 | }); 76 | 77 | // 设置版本 78 | program.version("2.0.0"); 79 | // program.option("-i", "生成iOS图标"); 80 | // program.option("-a", "生成安卓图标"); 81 | // program.option("-p", "生成资源"); 82 | 83 | program.parse(process.argv); 84 | -------------------------------------------------------------------------------- /tools/file.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const join = require("path").join; 3 | 4 | function file(path) { 5 | // console.log("read file:", path); 6 | return new Promise((r, e) => { 7 | fs.readFile(path, "utf8", async function (err, data) { 8 | if (!err) { 9 | r(data); 10 | } else { 11 | console.error("read file error", err); 12 | e(err); 13 | } 14 | }); 15 | }); 16 | } 17 | 18 | async function copyFile(p1, p2, force) { 19 | if (!force) 20 | if (await exists(p2)) { 21 | console.log(`[INFO]文件 ${p2} 已存在,跳过拷贝`); 22 | return; 23 | } 24 | return new Promise((r, e) => { 25 | fs.copyFile(p1, p2, (error) => { 26 | if (error) { 27 | e(error); 28 | } else r(); 29 | }); 30 | }); 31 | } 32 | 33 | function exists(path) { 34 | return new Promise((r, e) => { 35 | fs.exists(path, function (exists) { 36 | if (exists) { 37 | r(true); 38 | } else { 39 | r(false); 40 | } 41 | }); 42 | }); 43 | } 44 | 45 | // 查找目录下文件 46 | function find(startPath) { 47 | let result = []; 48 | fs.mkdirSync(startPath, { 49 | recursive: true, 50 | }); 51 | function finder(path) { 52 | let files = fs.readdirSync(path); 53 | files.forEach((val, index) => { 54 | let fPath = join(path, val); 55 | let stats = fs.statSync(fPath); 56 | if (stats.isDirectory()) result.push(fPath); 57 | if (stats.isFile()) result.push(fPath); 58 | }); 59 | } 60 | finder(startPath); 61 | return result; 62 | } 63 | 64 | // 递归查找所有文件 65 | function findAll(startPath) { 66 | let result = []; 67 | function finder(path) { 68 | let files = fs.readdirSync(path); 69 | files.forEach((val, index) => { 70 | let fPath = join(path, val); 71 | let stats = fs.statSync(fPath); 72 | if (stats.isDirectory()) finder(fPath); 73 | if (stats.isFile()) result.push(fPath); 74 | }); 75 | } 76 | finder(startPath); 77 | return result; 78 | } 79 | 80 | function savefile(path, content) { 81 | console.log("保存文件", path); 82 | var targetPath = path.split("/"); 83 | targetPath.pop(); 84 | targetPath = targetPath.join("/"); 85 | fs.mkdirSync(targetPath, { 86 | recursive: true, 87 | }); 88 | return new Promise((r, e) => { 89 | fs.writeFile(path, content, {}, async function (err) { 90 | if (!err) { 91 | r(); 92 | } else { 93 | console.error("save file error", err); 94 | e(err); 95 | } 96 | }); 97 | }); 98 | } 99 | 100 | function mkdir(path) { 101 | return new Promise((r, e) => { 102 | fs.mkdir(path, async function (err) { 103 | r(); 104 | }); 105 | }); 106 | } 107 | 108 | function resolve(dir) { 109 | return join(__dirname, dir); 110 | } 111 | 112 | module.exports = { 113 | find, 114 | file, 115 | savefile, 116 | mkdir, 117 | resolve, 118 | exists, 119 | copyFile, 120 | }; 121 | -------------------------------------------------------------------------------- /assets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > 在《浅谈Flutter的优缺点》文章中,我指出了Flutter存在切图困难,资源管理困难的缺陷,所以我使用node.js编写了一个小工具,可以帮您快速生成低倍率图片,并为iOS与安卓生成各自平台的图标。 2 | 3 | ## 提前全局安装 4 | - flutter 5 | - node.js环境 下载:https://nodejs.org/zh-cn/ 下好安装即可,很简单 6 | - npm包管理工具(Node自带) 7 | 8 | # fmaker功能 9 | 10 | 11 | 12 | fmaker是一个flutter辅助图片处理工具,也可以用来给iOS或Android项目生成图标 13 | 14 | 指令帮助: 15 | ```bash 16 | fmaker -h 17 | Usage: fmaker [options] [command] 18 | 19 | Options: 20 | -V, --version output the version number 21 | -h, --help display help for command 22 | 23 | Commands: 24 | init 在一个Flutter项目中初始化tmaker,为你创建文件夹,添加示例文件和添加.gitignore参数 25 | build [parts] 创建资源,可指定创建指定部分,例: fmaker build ios,android,assets 26 | preview 仅创建资源的预览注释,也就是r.preview.dart文件 27 | folder 把app的图标渲染在本项目的文件夹上(仅mac) 28 | help [command] display help for command 29 | ``` 30 | 31 | ### 按倍率生成图片 32 | `fmaker`可以自动识别项目下`/assets/fmaker`中的多倍图,将多倍图按flutter格式递归转换为2.0x,3.0x,4.0x等文件夹,再将压缩后的低倍图保存到assets中,保证flutter可以自动识别低倍率的图片。例如,在文件夹下放置`example@3x.png`,会生成三倍图,两倍图和一倍图。 33 | 34 | > 为什么要这样做? 35 | 36 | 因为高分辨率的图片被缩小时,会产生不必要的锐化效果,偶尔会产生卡顿;小图被放大时,会变得很模糊,flutter提供一个功能,自动显示正确分辨率的图片。 37 | 但是使用这个功能困难重重,如果你的设计使用sketch切图,只能切出`image.png`,`image@2x.png`,`image@3x.png`这种图,但是flutter需要的图片目录格式是`image.png`,`2.0x/image.png`,`3.0x/image.png`,这种格式使用sketch是很难一次导出的(需要每一次都更改导出名称),很不好用。 38 | 39 | ### 生成App图标 40 | 41 | 如果`/assets/fmaker`文件夹下有名为`ios_icon.png`和`android_icon.png`的文件,那么`fmaker`会自动识别这两个文件,直接将图标生成到项目中,不需要额外的复制粘贴。 42 | 43 | > 注意:iOS的图标不可含有alpha通道,Android的图标可以包含。共同的一点是,图标必须是正方形,`fmaker`会帮你检查icon尺寸,并在log中输出错误。 44 | ### 生成文件夹图标 45 | 46 | 在项目目录下运行: 47 | 48 | ``` 49 | fmaker folder 50 | ``` 51 | 52 | 脚本会自动把Icon?加入.gitignore。 53 | 如下加入即可: 54 | ``` 55 | Icon? 56 | ``` 57 | ### 生成yaml引用与r.dart 58 | 59 | 为了方便`flutter`使用,现在会自动生成yaml的资源引用,你需要先添加: 60 | 61 | ```yaml 62 | flutter: 63 | uses-material-design: true 64 | assets: 65 | # 添加下面这一句 66 | # fmaker 67 | ``` 68 | 那么在运行`fmaker build`后,就会自动生成: 69 | ```yaml 70 | flutter: 71 | uses-material-design: true 72 | assets: 73 | # fmaker 74 | - assets/example.png 75 | # fmaker-end 76 | ``` 77 | 对应的,也会在lib目录下生成r.dart文件,变量名会自动转为驼峰形式 78 | ```dart 79 | class R { 80 | static final String aqweqAsqQweqDasQwr = 'assets/aqweq-asq_qweq-das_qwr.png'; 81 | static final String assfaAbAResize = 'assets/assfa(ab)a-resize.png'; 82 | static final String example = 'assets/example.png'; 83 | } 84 | ``` 85 | 86 | # 安装 87 | 88 | ```bash 89 | git clone https://github.com/mjl0602/flutter-assets-maker.git 90 | cd flutter-assets-maker 91 | npm install -g 92 | fmaker 93 | ``` 94 | 如果看到,“没有对应指令,fmaker已安装”的log,就已经安装成功。 95 | 96 | # 使用 97 | 先假定你的项目名叫yourFlutterProject。 98 | 99 | 需要准备icon文件,`ios_icon.png`和`ios_android.png`,放在yourFlutterProject/assets/fmaker下,其他的多倍图也可以放进去,例如example@3x.png。 100 | 101 | Tips:如果找不到合规的文件又想试一试,使用fmaker init来使用我的测试图片。 102 | 103 | ```bash 104 | cd yourFlutterProject 105 | fmaker init #如果暂时找不到图,就用我的图测试 106 | fmaker build 107 | ``` 108 | 然后安卓与iOS的App图标都已经被替换,你可以启动项目来查看。 109 | 110 | # 注意 111 | 112 | - 工具理论上只支持png。 113 | - 工具会产生两个一样的图,一个是最高倍图,一个是源图,一定程度上增加了项目大小。 114 | - 建议不要引用fmaker文件夹中的源图,因为他不能被自动切换倍率。 115 | - fmaker的重复图片不会增加产物大小,只要你不引入源图。 116 | 117 | # 示例 118 | 119 | //TODO 120 | 有空就整个例子 121 | 122 | > 如果有bug,欢迎提issue,pr更好哦。 123 | > 仓库地址:https://github.com/mjl0602/flutter-assets-maker 124 | 125 | #未经作者授权,本文禁止转载 126 | 127 | 128 | -------------------------------------------------------------------------------- /assets/fileicon: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ### 4 | # Home page: https://github.com/mklement0/fileicon 5 | # Author: Michael Klement (http://same2u.net) 6 | # Invoke with: 7 | # --version for version information 8 | # --help for usage information 9 | ### 10 | 11 | # --- STANDARD SCRIPT-GLOBAL CONSTANTS 12 | 13 | kTHIS_NAME=${BASH_SOURCE##*/} 14 | kTHIS_HOMEPAGE='https://github.com/mklement0/fileicon' 15 | kTHIS_VERSION='v0.2.4' # NOTE: This assignment is automatically updated by `make version VER=` - DO keep the 'v' prefix. 16 | 17 | unset CDPATH # To prevent unpredictable `cd` behavior. 18 | 19 | # --- Begin: STANDARD HELPER FUNCTIONS 20 | 21 | die() { echo "$kTHIS_NAME: ERROR: ${1:-"ABORTING due to unexpected error."}" 1>&2; exit ${2:-1}; } 22 | dieSyntax() { echo "$kTHIS_NAME: ARGUMENT ERROR: ${1:-"Invalid argument(s) specified."} Use -h for help." 1>&2; exit 2; } 23 | 24 | # SYNOPSIS 25 | # openUrl 26 | # DESCRIPTION 27 | # Opens the specified URL in the system's default browser. 28 | openUrl() { 29 | local url=$1 platform=$(uname) cmd=() 30 | case $platform in 31 | 'Darwin') # OSX 32 | cmd=( open "$url" ) 33 | ;; 34 | 'CYGWIN_'*) # Cygwin on Windows; must call cmd.exe with its `start` builtin 35 | cmd=( cmd.exe /c start '' "$url " ) # !! Note the required trailing space. 36 | ;; 37 | 'MINGW32_'*) # MSYS or Git Bash on Windows; they come with a Unix `start` binary 38 | cmd=( start '' "$url" ) 39 | ;; 40 | *) # Otherwise, assume a Freedesktop-compliant OS, which includes many Linux distros, PC-BSD, OpenSolaris, ... 41 | cmd=( xdg-open "$url" ) 42 | ;; 43 | esac 44 | "${cmd[@]}" || { echo "Cannot locate or failed to open default browser; please go to '$url' manually." >&2; return 1; } 45 | } 46 | 47 | # Prints the embedded Markdown-formatted man-page source to stdout. 48 | printManPageSource() { 49 | /usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/ { s///; t\np;}' "$BASH_SOURCE" 50 | } 51 | 52 | # Opens the man page, if installed; otherwise, tries to display the embedded Markdown-formatted man-page source; if all else fails: tries to display the man page online. 53 | openManPage() { 54 | local pager embeddedText 55 | if ! man 1 "$kTHIS_NAME" 2>/dev/null; then 56 | # 2nd attempt: if present, display the embedded Markdown-formatted man-page source 57 | embeddedText=$(printManPageSource) 58 | if [[ -n $embeddedText ]]; then 59 | pager='more' 60 | command -v less &>/dev/null && pager='less' # see if the non-standard `less` is available, because it's preferable to the POSIX utility `more` 61 | printf '%s\n' "$embeddedText" | "$pager" 62 | else # 3rd attempt: open the the man page on the utility's website 63 | openUrl "${kTHIS_HOMEPAGE}/doc/${kTHIS_NAME}.md" 64 | fi 65 | fi 66 | } 67 | 68 | # Prints the contents of the synopsis chapter of the embedded Markdown-formatted man-page source for quick reference. 69 | printUsage() { 70 | local embeddedText 71 | # Extract usage information from the SYNOPSIS chapter of the embedded Markdown-formatted man-page source. 72 | embeddedText=$(/usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/!d; /^## SYNOPSIS$/,/^#/{ s///; t\np; }' "$BASH_SOURCE") 73 | if [[ -n $embeddedText ]]; then 74 | # Print extracted synopsis chapter - remove backticks for uncluttered display. 75 | printf '%s\n\n' "$embeddedText" | tr -d '`' 76 | else # No SYNOPIS chapter found; fall back to displaying the man page. 77 | echo "WARNING: usage information not found; opening man page instead." >&2 78 | openManPage 79 | fi 80 | } 81 | 82 | # --- End: STANDARD HELPER FUNCTIONS 83 | 84 | # --- PROCESS STANDARD, OUTPUT-INFO-THEN-EXIT OPTIONS. 85 | case $1 in 86 | --version) 87 | # Output version number and exit, if requested. 88 | ver="v0.2.4"; echo "$kTHIS_NAME $kTHIS_VERSION"$'\nFor license information and more, visit '"$kTHIS_HOMEPAGE"; exit 0 89 | ;; 90 | -h|--help) 91 | # Print usage information and exit. 92 | printUsage; exit 93 | ;; 94 | --man) 95 | # Display the manual page and exit. 96 | openManPage; exit 97 | ;; 98 | --man-source) # private option, used by `make update-doc` 99 | # Print raw, embedded Markdown-formatted man-page source and exit 100 | printManPageSource; exit 101 | ;; 102 | --home) 103 | # Open the home page and exit. 104 | openUrl "$kTHIS_HOMEPAGE"; exit 105 | ;; 106 | esac 107 | 108 | # --- Begin: SPECIFIC HELPER FUNCTIONS 109 | 110 | # NOTE: The functions below operate on byte strings such as the one above: 111 | # A single single string of pairs of hex digits, without separators or line breaks. 112 | # Thus, a given byte position is easily calculated: to get byte $byteIndex, use 113 | # ${byteString:byteIndex*2:2} 114 | 115 | # Outputs the specified EXTENDED ATTRIBUTE VALUE as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000A2C". 116 | # IMPORTANT: Hex. digits > 9 use UPPPERCASE characters. 117 | # getAttribByteString 118 | getAttribByteString() { 119 | xattr -px "$2" "$1" | tr -d ' \n' 120 | return ${PIPESTATUS[0]} 121 | } 122 | 123 | # Outputs the specified file's RESOURCE FORK as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000a2c". 124 | # IMPORTANT: Hex. digits > 9 use *lowercase* characters. 125 | # Note: This function relies on `xxd -p /..namedfork/rsrc | tr -d '\n'` rather than the conceptually equivalent `getAttributeByteString com.apple.ResourceFork` 126 | # for PERFORMANCE reasons: getAttributeByteString() relies on `xattr`, which is a *Python* script and therefore quite slow due to Python's startup cost. 127 | # getAttribByteString 128 | getResourceByteString() { 129 | xxd -p "$1"/..namedfork/rsrc | tr -d '\n' 130 | } 131 | 132 | # Patches a single byte in the byte string provided via stdin. 133 | # patchByteInByteString ndx byteSpec 134 | # ndx is the 0-based byte index 135 | # - If has NO prefix: becomes the new byte 136 | # - If has prefix '|': "adds" the value: the result of a bitwise OR with the existing byte becomes the new byte 137 | # - If has prefix '~': "removes" the value: the result of a applying a bitwise AND with the bitwise complement of to the existing byte becomes the new byte 138 | patchByteInByteString() { 139 | local ndx=$1 byteSpec=$2 byteVal byteStr charPos op='' charsBefore='' charsAfter='' currByte 140 | byteStr=$( 0 && charPos < ${#byteStr} )) || return 1 159 | # Determine the target byte, and strings before and after the byte to patch. 160 | (( charPos >= 2 )) && charsBefore=${byteStr:0:charPos} 161 | charsAfter=${byteStr:charPos + 2} 162 | # Determine the new byte value 163 | if [[ -n $op ]]; then 164 | currByte=${byteStr:charPos:2} 165 | printf -v patchedByte '%02X' "$(( 0x${currByte} $op 0x${byteVal} ))" 166 | else 167 | patchedByte=$byteSpec 168 | fi 169 | printf '%s%s%s' "$charsBefore" "$patchedByte" "$charsAfter" 170 | } 171 | 172 | # hasAttrib 173 | hasAttrib() { 174 | xattr "$1" | /usr/bin/grep -Fqx "$2" 175 | } 176 | 177 | # hasIconsResource 178 | hasIconsResource() { 179 | local file=$1 180 | getResourceByteString "$file" | /usr/bin/grep -Fq "$kMAGICBYTES_ICNS_RESOURCE" 181 | } 182 | 183 | 184 | # setCustomIcon 185 | setCustomIcon() { 186 | 187 | local fileOrFolder=$1 imgFile=$2 188 | 189 | [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 3 190 | [[ -f $imgFile ]] || return 3 191 | 192 | # !! 193 | # !! Sadly, Apple decided to remove the `-i` / `--addicon` option from the `sips` utility. 194 | # !! Therefore, use of *Cocoa* is required, which we do *via Python*, which has the added advantage 195 | # !! of creating a *set* of icons from the source image, scaling as necessary to create a 196 | # !! 512 x 512 top resolution icon (whereas sips -i created a single, 128 x 128 icon). 197 | # !! Thanks, https://apple.stackexchange.com/a/161984/28668 198 | # !! 199 | # !! Note: setIcon_forFile_options_() seemingly always indicates True, even with invalid image files, so 200 | # !! we attempt no error handling in the Python code. 201 | /usr/bin/python - "$imgFile" "$fileOrFolder" <<'EOF' || return 202 | import Cocoa 203 | import sys 204 | 205 | Cocoa.NSWorkspace.sharedWorkspace().setIcon_forFile_options_(Cocoa.NSImage.alloc().initWithContentsOfFile_(sys.argv[1].decode('utf-8')), sys.argv[2].decode('utf-8'), 0) 206 | EOF 207 | 208 | 209 | # Verify that a resource fork with icons was actually created. 210 | # For *files*, the resource fork is embedded in the file itself. 211 | # For *folders* a hidden file named $'Icon\r' is created *inside the folder*. 212 | [[ -d $fileOrFolder ]] && fileWithResourceFork=${fileOrFolder}/$kFILENAME_FOLDERCUSTOMICON || fileWithResourceFork=$fileOrFolder 213 | hasIconsResource "$fileWithResourceFork" || { 214 | cat >&2 < 226 | getCustomIcon() { 227 | 228 | local fileOrFolder=$1 icnsOutFile=$2 byteStr fileWithResourceFork byteOffset byteCount 229 | 230 | [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3 231 | 232 | # Determine what file to extract the resource fork from. 233 | if [[ -d $fileOrFolder ]]; then 234 | fileWithResourceFork=${fileOrFolder}/$kFILENAME_FOLDERCUSTOMICON 235 | [[ -f $fileWithResourceFork ]] || { echo "Custom-icon file does not exist: '${fileWithResourceFork/$'\r'/\\r}'" >&2; return 1; } 236 | else 237 | fileWithResourceFork=$fileOrFolder 238 | fi 239 | 240 | # Determine (based on format description at https://en.wikipedia.org/wiki/Apple_Icon_Image_format): 241 | # - the byte offset at which the icns resource begins, via the magic literal identifying an icns resource 242 | # - the length of the resource, which is encoded in the 4 bytes right after the magic literal. 243 | read -r byteOffset byteCount < <(getResourceByteString "$fileWithResourceFork" | /usr/bin/awk -F "$kMAGICBYTES_ICNS_RESOURCE" '{ printf "%s %d", (length($1) + 2) / 2, "0x" substr($2, 0, 8) }') 244 | (( byteOffset > 0 && byteCount > 0 )) || { echo "Custom-icon file contains no icons resource: '${fileWithResourceFork/$'\r'/\\r}'" >&2; return 1; } 245 | 246 | # Extract the actual bytes using tail and head and save them to the output file. 247 | tail -c "+${byteOffset}" "$fileWithResourceFork/..namedfork/rsrc" | head -c $byteCount > "$icnsOutFile" || return 248 | 249 | return 0 250 | } 251 | 252 | # removeCustomIcon 253 | removeCustomIcon() { 254 | 255 | local fileOrFolder=$1 byteStr 256 | 257 | [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 1 258 | 259 | # Step 1: Turn off the custom-icon flag in the com.apple.FinderInfo extended attribute. 260 | if hasAttrib "$fileOrFolder" com.apple.FinderInfo; then 261 | byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo | patchByteInByteString $kFI_BYTEOFFSET_CUSTOMICON '~'$kFI_VAL_CUSTOMICON) || return 262 | if [[ $byteStr == "$kFI_BYTES_BLANK" ]]; then # All bytes cleared? Remove the entire attribute. 263 | xattr -d com.apple.FinderInfo "$fileOrFolder" 264 | else # Update the attribute. 265 | xattr -wx com.apple.FinderInfo "$byteStr" "$fileOrFolder" || return 266 | fi 267 | fi 268 | 269 | # Step 2: Remove the resource fork (if target is a file) / hidden file with custom icon (if target is a folder) 270 | if [[ -d $fileOrFolder ]]; then 271 | rm -f "${fileOrFolder}/${kFILENAME_FOLDERCUSTOMICON}" 272 | else 273 | if hasIconsResource "$fileOrFolder"; then 274 | xattr -d com.apple.ResourceFork "$fileOrFolder" 275 | fi 276 | fi 277 | 278 | return 0 279 | } 280 | 281 | # testForCustomIcon 282 | testForCustomIcon() { 283 | 284 | local fileOrFolder=$1 byteStr byteVal fileWithResourceFork 285 | 286 | [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3 287 | 288 | # Step 1: Check if the com.apple.FinderInfo extended attribute has the custom-icon 289 | # flag set. 290 | byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo 2>/dev/null) || return 1 291 | 292 | byteVal=${byteStr:2*kFI_BYTEOFFSET_CUSTOMICON:2} 293 | 294 | (( byteVal & kFI_VAL_CUSTOMICON )) || return 1 295 | 296 | # Step 2: Check if the resource fork of the relevant file contains an icns resource 297 | if [[ -d $fileOrFolder ]]; then 298 | fileWithResourceFork=${fileOrFolder}/${kFILENAME_FOLDERCUSTOMICON} 299 | else 300 | fileWithResourceFork=$fileOrFolder 301 | fi 302 | 303 | hasIconsResource "$fileWithResourceFork" || return 1 304 | 305 | return 0 306 | } 307 | 308 | # --- End: SPECIFIC HELPER FUNCTIONS 309 | 310 | # --- Begin: SPECIFIC SCRIPT-GLOBAL CONSTANTS 311 | 312 | kFILENAME_FOLDERCUSTOMICON=$'Icon\r' 313 | 314 | # The blank hex dump form (single string of pairs of hex digits) of the 32-byte data structure stored in extended attribute 315 | # com.apple.FinderInfo 316 | kFI_BYTES_BLANK='0000000000000000000000000000000000000000000000000000000000000000' 317 | 318 | # The hex dump form of the full 32 bytes that Finder assigns to the hidden $'Icon\r' 319 | # file whose com.apple.ResourceFork extended attribute contains the icon image data for the enclosing folder. 320 | # The first 8 bytes spell out the magic literal 'iconMACS'; they are followed by the invisibility flag, '40' in the 9th byte, and '10' (?? specifying what?) 321 | # in the 10th byte. 322 | # NOTE: Since file $'Icon\r' serves no other purpose than to store the icon, it is 323 | # safe to simply assign all 32 bytes blindly, without having to worry about 324 | # preserving existing values. 325 | kFI_BYTES_CUSTOMICONFILEFORFOLDER='69636F6E4D414353401000000000000000000000000000000000000000000000' 326 | 327 | # The hex dump form of the magic literal inside a resource fork that marks the 328 | # start of an icns (icons) resource. 329 | # NOTE: This will be used with `xxd -p .. | tr -d '\n'`, which uses *lowercase* 330 | # hex digits, so we must use lowercase here. 331 | kMAGICBYTES_ICNS_RESOURCE='69636e73' 332 | 333 | # The byte values (as hex strings) of the flags at the relevant byte position 334 | # of the com.apple.FinderInfo extended attribute. 335 | kFI_VAL_CUSTOMICON='04' 336 | 337 | # The custom-icon-flag byte offset in the com.apple.FinderInfo extended attribute. 338 | kFI_BYTEOFFSET_CUSTOMICON=8 339 | 340 | # --- End: SPECIFIC SCRIPT-GLOBAL CONSTANTS 341 | 342 | # Option defaults. 343 | force=0 quiet=0 344 | 345 | # --- Begin: OPTIONS PARSING 346 | allowOptsAfterOperands=1 operands=() i=0 optName= isLong=0 prefix= optArg= haveOptArgAttached=0 haveOptArgAsNextArg=0 acceptOptArg=0 needOptArg=0 347 | while (( $# )); do 348 | if [[ $1 =~ ^(-)[a-zA-Z0-9]+.*$ || $1 =~ ^(--)[a-zA-Z0-9]+.*$ ]]; then # an option: either a short option / multiple short options in compressed form or a long option 349 | prefix=${BASH_REMATCH[1]}; [[ $prefix == '--' ]] && isLong=1 || isLong=0 350 | for (( i = 1; i < (isLong ? 2 : ${#1}); i++ )); do 351 | acceptOptArg=0 needOptArg=0 haveOptArgAttached=0 haveOptArgAsNextArg=0 optArgAttached= optArgOpt= optArgReq= 352 | if (( isLong )); then # long option: parse into name and, if present, argument 353 | optName=${1:2} 354 | [[ $optName =~ ^([^=]+)=(.*)$ ]] && { optName=${BASH_REMATCH[1]}; optArgAttached=${BASH_REMATCH[2]}; haveOptArgAttached=1; } 355 | else # short option: *if* it takes an argument, the rest of the string, if any, is by definition the argument. 356 | optName=${1:i:1}; optArgAttached=${1:i+1}; (( ${#optArgAttached} >= 1 )) && haveOptArgAttached=1 357 | fi 358 | (( haveOptArgAttached )) && optArgOpt=$optArgAttached optArgReq=$optArgAttached || { (( $# > 1 )) && { optArgReq=$2; haveOptArgAsNextArg=1; }; } 359 | # ---- BEGIN: CUSTOMIZE HERE 360 | case $optName in 361 | f|force) 362 | force=1 363 | ;; 364 | q|quiet) 365 | quiet=1 366 | ;; 367 | *) 368 | dieSyntax "Unknown option: ${prefix}${optName}." 369 | ;; 370 | esac 371 | # ---- END: CUSTOMIZE HERE 372 | (( needOptArg )) && { (( ! haveOptArgAttached && ! haveOptArgAsNextArg )) && dieSyntax "Option ${prefix}${optName} is missing its argument." || (( haveOptArgAsNextArg )) && shift; } 373 | (( acceptOptArg || needOptArg )) && break 374 | done 375 | else # an operand 376 | if [[ $1 == '--' ]]; then 377 | shift; operands+=( "$@" ); break 378 | elif (( allowOptsAfterOperands )); then 379 | operands+=( "$1" ) # continue 380 | else 381 | operands=( "$@" ) 382 | break 383 | fi 384 | fi 385 | shift 386 | done 387 | (( "${#operands[@]}" > 0 )) && set -- "${operands[@]}"; unset allowOptsAfterOperands operands i optName isLong prefix optArgAttached haveOptArgAttached haveOptArgAsNextArg acceptOptArg needOptArg 388 | # --- End: OPTIONS PARSING: "$@" now contains all operands (non-option arguments). 389 | 390 | # Validate the command 391 | cmd=$(printf %s "$1" | tr '[:upper:]' '[:lower:]') # translate to all-lowercase - we don't want the command name to be case-sensitive 392 | [[ $cmd == 'remove' ]] && cmd='rm' # support alias 'remove' for 'rm' 393 | case $cmd in 394 | set|get|rm|remove|test) 395 | shift 396 | ;; 397 | *) 398 | dieSyntax "Unrecognized or missing command: '$cmd'." 399 | ;; 400 | esac 401 | 402 | # Validate file operands 403 | (( $# > 0 )) || dieSyntax "Missing operand(s)." 404 | 405 | # Target file or folder. 406 | targetFileOrFolder=$1 imgFile= outFile= 407 | [[ -f $targetFileOrFolder || -d $targetFileOrFolder ]] || die "Target not found or neither file nor folder: '$targetFileOrFolder'" 408 | # Make sure the target file/folder is readable, and, unless only getting or testing for an icon are requested, writeable too. 409 | [[ -r $targetFileOrFolder ]] || die "Cannot access '$targetFileOrFolder': you do not have read permissions." 410 | [[ $cmd == 'test' || $cmd == 'get' || -w $targetFileOrFolder ]] || die "Cannot modify '$targetFileOrFolder': you do not have write permissions." 411 | 412 | # Other operands, if any, and their number. 413 | valid=0 414 | case $cmd in 415 | 'set') 416 | (( $# <= 2 )) && { 417 | valid=1 418 | # If no image file was specified, the target file is assumed to be an image file itself whose image should be self-assigned as an icon. 419 | (( $# == 2 )) && imgFile=$2 || imgFile=$1 420 | # !! Apparently, a regular file is required - a process subsitution such 421 | # !! as `<(base64 -D ' 504 | # - All other headings should be level-2 headings in ALL-CAPS. 505 | # - TEXT 506 | # - Use NO indentation for regular chapter text; if you do, it will 507 | # be indented further than list items. 508 | # - Use 4-space indentation, as usual, for code blocks. 509 | # - Markup character-styling markup translates to ROFF rendering as follows: 510 | # `...` and **...** render as bolded (red) text 511 | # _..._ and *...* render as word-individually underlined text 512 | # - LISTS 513 | # - Indent list items by 2 spaces for better plain-text viewing, but note 514 | # that the ROFF generated by marked-man still renders them unindented. 515 | # - End every list item (bullet point) itself with 2 trailing spaces too so 516 | # that it renders on its own line. 517 | # - Avoid associating more than 1 paragraph with a list item, if possible, 518 | # because it requires the following trick, which hampers plain-text readability: 519 | # Use ' ' in lieu of an empty line. 520 | #### 521 | : <<'EOF_MAN_PAGE' 522 | # fileicon(1) - manage file and folder custom icons 523 | 524 | ## SYNOPSIS 525 | 526 | Manage custom icons for files and folders on macOS. 527 | 528 | SET a custom icon for a file or folder: 529 | 530 | fileicon set [] 531 | 532 | REMOVE a custom icon from a file or folder: 533 | 534 | fileicon rm 535 | 536 | GET a file or folder's custom icon: 537 | 538 | fileicon get [-f] [] 539 | 540 | -f ... force replacement of existing output file 541 | 542 | TEST if a file or folder has a custom icon: 543 | 544 | fileicon test 545 | 546 | All forms: option -q silences status output. 547 | 548 | Standard options: `--help`, `--man`, `--version`, `--home` 549 | 550 | ## DESCRIPTION 551 | 552 | `` is the file or folder whose custom icon should be managed. 553 | Note that symlinks are followed to their (ultimate target); that is, you 554 | can only assign custom icons to regular files and folders, not to symlinks 555 | to them. 556 | 557 | `` can be an image file of any format supported by the system. 558 | It is converted to an icon and assigned to ``. 559 | If you omit ``, `` must itself be an image file whose 560 | image should become its own icon. 561 | 562 | `` specifies the file to extract the custom icon to: 563 | Defaults to the filename of `` with extension `.icns` appended. 564 | If a value is specified, extension `.icns` is appended, unless already present. 565 | Either way, extraction fails if the target file already exists; use `-f` to 566 | override. 567 | Specify `-` to extract to stdout. 568 | 569 | Command `test` signals with its exit code whether a custom icon is set (0) 570 | or not (1); any other exit code signals an unexpected error. 571 | 572 | **Options**: 573 | 574 | * `-f`, `--force` 575 | When getting (extracting) a custom icon, forces replacement of the 576 | output file, if it already exists. 577 | 578 | * `-q`, `--quiet` 579 | Suppresses output of the status information that is by default output to 580 | stdout. 581 | Note that errors and warnings are still printed to stderr. 582 | 583 | ## NOTES 584 | 585 | Custom icons are stored in extended attributes of the HFS+ filesystem. 586 | Thus, if you copy files or folders to a different filesystem that doesn't 587 | support such attributes, custom icons are lost; for instance, custom icons 588 | cannot be stored in a Git repository. 589 | 590 | To determine if a give file or folder has extended attributes, use 591 | `ls -l@ `. 592 | 593 | When setting an image as a custom icon, a set of icons with several resolutions 594 | is created, with the highest resolution at 512 x 512 pixels. 595 | 596 | All icons created are square, so images with a non-square aspect ratio will 597 | appear distorted; for best results, use square imges. 598 | 599 | ## STANDARD OPTIONS 600 | 601 | All standard options provide information only. 602 | 603 | * `-h, --help` 604 | Prints the contents of the synopsis chapter to stdout for quick reference. 605 | 606 | * `--man` 607 | Displays this manual page, which is a helpful alternative to using `man`, 608 | if the manual page isn't installed. 609 | 610 | * `--version` 611 | Prints version information. 612 | 613 | * `--home` 614 | Opens this utility's home page in the system's default web browser. 615 | 616 | ## LICENSE 617 | 618 | For license information and more, visit the home page by running 619 | `fileicon --home` 620 | 621 | EOF_MAN_PAGE 622 | --------------------------------------------------------------------------------