├── .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 |
--------------------------------------------------------------------------------