├── .editorconfig
├── .env
├── .env.development
├── .env.production
├── .eslintrc.js
├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── docs
├── config.md
├── questions.md
├── quick-start.md
└── widget-element.md
├── nodemon.json
├── package.json
├── prettier.config.js
├── scriptable.config.js
├── src
├── index.ts
├── lib
│ ├── basic.ts
│ ├── compile.ts
│ ├── components
│ │ ├── index.ts
│ │ └── layout.tsx
│ ├── constants.ts
│ ├── env.ts
│ ├── help.ts
│ ├── jsx-runtime.ts
│ ├── server.ts
│ └── static
│ │ ├── dev-help.html
│ │ └── 基础包.js
├── scripts
│ ├── bili.tsx
│ ├── china10010.tsx
│ ├── funds.tsx
│ ├── helloWorld.tsx
│ ├── launcher.tsx
│ ├── music163.tsx
│ ├── newsTop.tsx
│ └── yiyan.tsx
└── types
│ ├── JSX.d.ts
│ ├── index.d.ts
│ └── widget
│ ├── index.d.ts
│ ├── wbox.d.ts
│ ├── wdate.d.ts
│ ├── wimage.d.ts
│ ├── wspacer.d.ts
│ ├── wstack.d.ts
│ └── wtext.d.ts
├── tsconfig.json
└── 打包好的成品
├── install.json
├── 一言.js
├── 个性磁贴启动器.js
├── 今日热榜.js
├── 哔哩粉丝.js
├── 基金小部件.js
├── 网易云歌单.js
└── 联通流量话费小部件.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | HELLO = '你好 env'
2 | MOMENT = '这是我 .env 独享的 moment'
3 |
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | HELLO = '你好 development'
2 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | HELLO = '你好 production'
2 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | //@ts-check
2 |
3 | module.exports = /** @type { import('eslint').Linter.Config } */ ({
4 | root: true,
5 | env: {
6 | node: true,
7 | es6: true,
8 | browser: true,
9 | },
10 | // 指定 ESLint parser
11 | parser: '@typescript-eslint/parser',
12 | extends: [
13 | // 使用 @typescript-eslint/eslint-plugin 中推荐的规则
14 | 'plugin:@typescript-eslint/recommended',
15 | // 使用 eslint-config-prettier 来禁止 @typescript-eslint/eslint-plugin 中那些和 prettier 冲突的规则
16 | 'prettier/@typescript-eslint',
17 | // 确保下面这行配置是这个数组里的最后一行配置
18 | 'plugin:prettier/recommended',
19 | ],
20 | rules: {
21 | 'import/no-unresolved': 'off',
22 | 'no-unused-vars': 'off',
23 | '@typescript-eslint/no-unused-vars': ['warn', {varsIgnorePattern: '^(h|MODULE|Fragment){1}$'}],
24 | '@typescript-eslint/no-this-alias': 'off',
25 | '@typescript-eslint/triple-slash-reference': 'off',
26 | '@typescript-eslint/no-var-requires': 'off',
27 | },
28 | parserOptions: {
29 | parser: '@typescript-eslint/parser',
30 | ecmaversion: 2015, // Allows for the parsing of modern ECMAScript features
31 | sourceType: 'module', // Allows for the use of imports
32 | ecmaFeatures: {
33 | tsx: true,
34 | },
35 | },
36 | })
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | package-lock.json
4 |
5 | # local env files
6 | .env.local
7 | .env.*.local
8 |
9 | # Log files
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // 保存时使用VSCode 自身格式化程序格式化
3 | "editor.formatOnSave": true,
4 | // 启用eslint
5 | "eslint.enable": true,
6 | // 保存时用eslint格式化
7 | "editor.codeActionsOnSave": {
8 | "source.fixAll.eslint": true
9 | },
10 | // 两者会在格式化js时冲突,所以需要关闭默认js格式化程序
11 | "javascript.format.enable": false,
12 | "html.format.enable": false,
13 | "json.format.enable": false,
14 | "typescript.format.enable": false
15 | }
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020-present, Yang xiaoming
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |

2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ios-scriptable-tsx
22 |
23 |
24 |
25 | ## 介绍
26 |
27 | 本项目旨在给 `Scriptable` 开发者提供舒适的开发体验, `Scriptable` 是 ios 上一个用 js 开发桌面小组件的 app ,如果你还没安装,可以[点我下载](https://apps.apple.com/us/app/scriptable/id1405459188)
28 |
29 |
30 |
31 | `ios-scriptable-tsx` 是一个二次封装 `Scriptable` 官方 api 的开发框架,它具有以下特点:
32 |
33 |
34 |
35 | **1. 支持在 pc 开发,支持实时监听修改、编译同步到手机运行。**
36 |
37 |
38 |
39 | **2. 支持使用 typescript 和 tsx 开发小组件,支持 api 类型提示、自动补全。**
40 |
41 |
42 |
43 | **3. 支持打包混淆、加密 js。**
44 |
45 |
46 |
47 | **4. 支持远程输出 console 日志到 pc 的命令窗口。**
48 |
49 |
50 |
51 | **5. 支持环境变量定义,打包时自动替换环境变量为预设值。**
52 |
53 |
54 |
55 | **6. 常用函数封装,使用更便捷。**
56 |
57 |
58 |
59 | ## 使用
60 |
61 | 1. 先克隆本仓库
62 |
63 | ```bash
64 | git clone https://github.com/2214962083/ios-scriptable-tsx.git
65 | ```
66 |
67 |
68 |
69 | 2. 进入到 `ios-scriptable-tsx` 目录里,执行 `npm install` 安装依赖
70 |
71 |
72 |
73 |
74 | 3. 打开 vscode 愉快开发,打包入口文件默认是 `./src/index.ts`
75 |
76 |
77 |
78 |
79 | 4. 执行 `npm run build` 打包到 `./dist` 文件夹
80 |
81 |
82 |
83 | ## 文档
84 | - [快速上手](./docs/quick-start.md#quick-start)
85 | - [命令说明](./docs/quick-start.md#command-introduction)
86 | - [项目目录说明和配置指南](./docs/config.md#config-introduction)
87 | - [项目目录说明](./docs/config.md#project-dir-introduction)
88 | - [打包配置](./docs/config.md#scriptable-config)
89 | - [其他配置](./docs/config.md#others-config)
90 | - [环境变量](./docs/config.md#env-config)
91 | - [JSX 组件](./docs/widget-element.md#jsx-element)
92 | - [wbox 盒子组件](./docs/widget-element.md#wbox)
93 | - [wstack 容器组件](./docs/widget-element.md#wstack)
94 | - [wimage 图片组件](./docs/widget-element.md#wimage)
95 | - [wspacer 空格占位组件](./docs/widget-element.md#wspacer)
96 | - [wtext 文字组件](./docs/widget-element.md#wtext)
97 | - [wdate 日期组件](./docs/widget-element.md#wdate)
98 | - [常用函数封装](https://github.com/2214962083/ios-scriptable-tsx/blob/master/src/lib/help.ts)
99 | - [常见问题](./docs/questions.md)
100 | - [Scriptable 官方 api 文档](https://docs.scriptable.app/)
101 |
102 | ## 讨论
103 |
104 | - [Scriptable 官方论坛](https://talk.automators.fm/c/scriptable/13)
105 |
106 | ## 项目参考
107 |
108 | - [「小件件」开发框架](https://github.com/im3x/Scriptables)
109 | - [Scriptable](https://github.com/dompling/Scriptable)
110 | - [Transparent-Scriptable-Widget](https://github.com/mzeryck/Transparent-Scriptable-Widget)
111 |
112 | ## 许可证
113 |
114 | [MIT](https://opensource.org/licenses/MIT)
115 |
116 | Copyright (c) 2020-present, Yang xiaoming
117 |
--------------------------------------------------------------------------------
/docs/config.md:
--------------------------------------------------------------------------------
1 | # 项目目录说明和配置指南
2 |
3 |
4 |
5 | ## 项目目录说明
6 |
7 | ```
8 | ios-scriptable-tsx
9 | ├── .vscode // vscode 配置
10 | ├── 打包好的成品 // 可以粘贴到 scriptable 运行的成品
11 | ├── docs // 文档
12 | ├── src // 代码目录
13 | │ ├── lib // 本项目主要文件存放处,请勿挪动
14 | │ │ └── static // 存放开发引导文件的文件夹
15 | │ │ └── basic // 基础包源码
16 | │ │ └── compile.ts // 打包脚本,请勿随意更改
17 | │ │ └── constants.ts // 常量
18 | │ │ └── env.ts // 加载 dotenv 文件的脚本
19 | │ │ └── help.ts // 常用 api 函数封装
20 | │ │ └── jsx-runtime.ts // tsx、jsx 编译后解释回 scriptable api 的文件
21 | │ │ └── server.ts // 开启监听服务器,用于手机代码同步
22 | │ ├── scripts // 开发者存放小组件源代码的目录
23 | │ └── types // 存放类型声明目录
24 | │ │ └── widget // 存放 jsx、tsx element 可接收参数类型的目录
25 | │ └── index.ts // 打包主入口文件
26 | ├── .editorconfig // 编辑器配置
27 | ├── .env // dotenv 文件,打包就加载进环境变量
28 | ├── .env.development // dotenv 文件,开发环境时,加载进环境变量
29 | ├── .env.production // dotenv 文件,生产环境时,加载进环境变量
30 | ├── .eslintrc.js // eslint 配置,统一代码风格
31 | ├── .gitignore // gitignore 文件
32 | ├── nodemon.json // nodemon监听配置,在 watch 下生效
33 | ├── package.json // 项目信息、依赖声明文件
34 | ├── prettier.config.js // prettier 配置,用于美化、对齐代码
35 | ├── README.md // 文档
36 | ├── scriptable.config.js // scriptable 打包配置
37 | └── tsconfig.json // TypeScript 编译选项
38 | ```
39 |
40 |
41 |
42 | ## scriptable.config.js 打包配置
43 |
44 | | 属性 | 类型 | 必填 | 默认 | 描述 |
45 | |----------------|---------|----|----------------------|------------------------------|
46 | | rootPath | string | 否 | \./ | 项目根目录, 不建议修改 |
47 | | inputFile | string | 否 | \./src/index\.ts | 输入文件,当执行编译时生效,不建议修改 |
48 | | inputDir | string | 否 | \./src/scripts/ | 输入文件夹,当执行批量编译时生效,不建议修改 |
49 | | outputDir | string | 否 | \./dist/ | 输出文件夹,不建议修改 |
50 | | minify | boolean | 否 | 开发环境为false,生存环境为true | 是否压缩代码 |
51 | | encrypt | boolean | 否 | 开发环境为false,生存环境为true | 是否加密代码 |
52 | | header | string | 否 | | 往编译后的代码头部插入的代码(一般是作者信息) |
53 | | [esbuild](https://esbuild.github.io/api/#simple-options) | object | 否 | | esbuild 自定义配置 |
54 | | [encryptOptions](https://github.com/javascript-obfuscator/javascript-obfuscator) | object | 否 | | javascript\-obfuscator 自定义配置(加密代码配置) |
55 |
56 |
57 |
58 | ## 其他配置
59 |
60 | | 配置文件名 | 用途 |
61 | | -------------------------------------------------------- | ------------------------------ |
62 | | [.editorconfig](http://editorconfig.org) | 统一各个编辑器编辑风格(可删) |
63 | | [.eslintrc.js](https://cn.eslint.org/) | 定义代码规范(可删) |
64 | | [prettier.config.js](https://prettier.io) | 自动对齐、美化代码用(可删) |
65 | | [tsconfig.json](https://www.typescriptlang.org/tsconfig) | typescript配置文件 (不可删) |
66 |
67 |
68 |
69 | ## 环境变量配置
70 |
71 | `ios-scriptable-tsx` 提供两个环境模式,开发环境 `development` 模式和生产环境 `production`模式 ,你可以用代码 `process.env.NODE_ENV` 获取到这个值。你可以在项目根目录下的 `package.json` 文件里的 `scripts` 看到他们是怎么传进去的。
72 |
73 | ```bash
74 | npm run watch #development开发环境
75 | npm run dev #development开发环境
76 | npm run dev:all #development开发环境
77 | npm run build #production生产环境
78 | npm run build:all #production生产环境
79 | ```
80 |
81 |
82 |
83 | **本项目集成了[dotenv](https://github.com/motdotla/dotenv),你可以替换你的项目根目录中的下列文件来指定环境变量(如果你用过 [vue-cli](https://cli.vuejs.org/zh/guide/mode-and-env.html#%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E5%92%8C%E6%A8%A1%E5%BC%8F) 你会很熟悉它):**
84 |
85 | ```
86 | .env # 在所有的环境中被载入
87 | .env.local # 在所有的环境中被载入,但会被 git 忽略
88 | .env.[mode] # 只在指定的模式中被载入
89 | .env.[mode].local # 只在指定的模式中被载入,但会被 git 忽略
90 | ```
91 |
92 |
93 |
94 | 一个环境文件只包含环境变量的“键=值”对:
95 |
96 | ```
97 | FOO=bar
98 | HELLO=你好
99 | ```
100 |
101 | **环境变量将会载入挂载到 `process.env `上。例如在打包时,`process.env.FOO`将会被替换成字符串 `bar` ,`process.env.HELLO`将会被替换成字符串`你好`。被载入的变量将会对./src 目录下的所有代码可用。**
102 |
103 |
当为`development`开发环境打包时,下面的文件会被依次载入:
104 |
105 | ```
106 | .env
107 | .env.local
108 | .env.development
109 | .env.development.local
110 | ```
111 |
112 |
113 |
114 | 当为`production`生产环境打包时,下面的文件会被依次载入:
115 |
116 | ```
117 | .env
118 | .env.local
119 | .env.production
120 | .env.production.local
121 | ```
122 |
123 |
124 |
--------------------------------------------------------------------------------
/docs/questions.md:
--------------------------------------------------------------------------------
1 | ## 常见问题
2 |
3 |
4 |
5 | **A、为什么执行 `npm run watch` 同步后,修改编译但还是不同步?**
6 |
7 | 1、检查你的同步地址有没有填错
8 |
9 | 2、同步功能依赖基础包轮询 pc 的 server。如果你在基础包运行的时候,运行了其他脚本, `Scriptable` 就会终止基础包的进程。
10 |
11 | 此时再点击基础包执行同步就好,目前无解,因为 `Scriptable` 只能保持一个脚本在运行。
12 |
13 | 虽然可以通过注入同步代码到编译后的脚本来解决,但是会引入额外的代码干扰。本框架遵循一致性原则,编译是啥,同步过去就是啥。
14 |
15 | 所见即所得,不额外引入复杂度。即便断开同步也能如期运行。
16 |
17 |
18 |
19 |
20 |
21 | **B、如何停止同步代码?**
22 |
23 | 代码同步轮询,请求错误到一定次数就会断开,不用手动停止。
24 |
25 |
26 |
27 |
28 |
29 | **C、可以用 js 或 jsx 开发吗,without the typescript ?**
30 |
31 | 可以,只要你的代码在 `./src/index.ts` 引入就能打包。
32 |
33 |
34 |
35 |
36 |
37 | **D、为什么小部件偶尔会出现下面这种状况?**
38 |
39 |
40 |
41 | 这种状况是因为 `Scriptable` 内部处理多个小部件异步渲染时没处理好。导致部分小部件渲染为空。([在此可以看到相关讨论](https://talk.automators.fm/t/hellp-call-script-setwidget-to-set-the-content-of-the-widget-run-in-widget/9615/7))
42 |
43 | 解决方法也很简单,在渲染时最外层加个 await。由于打包器不支持 `top-level-await`,所以本框架内置了一个末尾底部等待的函数 `EndAwait` ,使用方法见下
44 |
45 | ```tsx
46 | Class HelloWorld {
47 | async init() {
48 | ....
49 | }
50 | async render() {
51 | .....
52 | }
53 | }
54 |
55 | // 使用前
56 | // new HelloWorld().init()
57 |
58 | // 使用后,这样就不会出现以上状况了
59 | EndAwait(() => new HelloWorld().init())
60 | ```
61 |
62 |
63 |
64 |
65 |
66 | **E、为什么 jsx widget 是异步渲染?**
67 |
68 | 为了方便引入网络资源,比如图片,实现 `wimage` 的 src 填写网络连接就能自动加载图片,是需要异步等待的。所以渲染小部件,返回 ListWidget 实例也是异步。
69 |
70 |
71 |
72 |
73 |
74 | **F、基础包同步运行时报错,而同步后,直接运行所同步的脚本却没报错?**
75 |
76 | 可能是基础包版本过旧原因,再扫二维码进引导页重新安装一下基础包即可。
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/docs/quick-start.md:
--------------------------------------------------------------------------------
1 | ## 快速上手
2 |
3 |
4 |
5 | 1. 克隆本项目:
6 |
7 | ```bash
8 | git clone https://github.com/2214962083/ios-scriptable-tsx.git
9 | ```
10 |
11 |
12 |
13 | 2. 进入 `ios-scriptable-tsx` 目录,执行 `npm install` 安装依赖
14 |
15 |
16 |
17 | 3. 在 `./src/scripts` 目录下新创建一个文件叫 `helloWorld.tsx`
18 |
19 |
20 |
21 | 4. 把下面代码粘贴上去:
22 |
23 | ```tsx
24 | class HelloWorld {
25 | async init() {
26 | // ListWidget 实例
27 | const widget = (await this.render()) as ListWidget
28 | // 注册小部件
29 | Script.setWidget(widget)
30 | // 调试用
31 | !config.runsInWidget && (await widget.presentMedium())
32 | // 脚本结束
33 | Script.complete()
34 | }
35 | async render(): Promise {
36 | return (
37 |
38 |
39 |
40 | Hello World
41 |
42 |
43 |
44 | )
45 | }
46 | }
47 |
48 | new HelloWorld().init()
49 | ```
50 |
51 |
52 |
53 | 5. 然后,去 `./src/index.ts` 删除已有的东西,引入`helloWorld.tsx`
54 |
55 | ```tsx
56 | // 打包入口默认是 ./src/index.ts
57 | // 这样引入其他位置的脚本,就会打包进来
58 | import './scripts/helloWorld.tsx'
59 | ```
60 |
61 |
62 |
63 | 6. 执行 `npm run watch`,并用手机扫描命令终端的二维码,打开引导页(**手机和 pc 必须处于同一个局域网下面**),我的是这样的:
64 |
65 |
66 |
67 |
68 |
69 | 7. 根据引导页提示操作,**此举是用来安装基础包,若已安装基础包,无需再进引导页**:
70 |
71 |
72 |
73 |
74 |
75 | 8. 在 `scriptable` 右上角 `+` 创建一个空脚本,命名为 `你好世界` :
76 |
77 |
78 |
79 |
80 |
81 | 9. 点击基础包,选择远程开发, 选择 `你好世界.js`:
82 |
83 |
84 |
85 |
86 |
87 | 10. 输入同步地址,`http://IP地址:端口/打包后的文件` ,默认是 pc 上提示的链接加上 `/index.js`,同步成功,看到小部件预览效果了:
88 |
89 |
90 |
91 |
92 |
93 |
94 | 11. 长按桌面,点击添加 `scriptable` 小部件,然后长按小部件,点击编辑小部件 :
95 |
96 |
97 |
98 |
99 |
100 |
101 | 12. `Script` 选择 `你好世界`,就能看到效果了,恭喜你完成新手教学 :
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | ## 命令说明
111 |
112 | | 命令 | 命令作用 | 补充说明 |
113 | | ------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
114 | | `npm run dev` | 以开发环境打包单个文件 | `process.env.NODE_ENV = development`
默认不混淆代码、不加密代码
单文件打包入口默认是`./src/index.ts`
可在 [scriptable.config.js](./config.md#scriptable-config) 里配置 `inputFile` 以修改它的打包入口文件。 |
115 | | `npm run dev:all` | 以开发环境打包多个文件 | `process.env.NODE_ENV = development`
默认不混淆代码、不加密代码
多文件打包入口默认是`./src/scripts/`
可在 [scriptable.config.js](./config.md#scriptable-config) 里配置 `inputDir` 以修改它的打包入口文件夹。 |
116 | | `npm run build` | 以生产环境打包单个文件 | `process.env.NODE_ENV = production`
默认混淆代码、加密代码
单文件打包入口默认是`./src/index.ts`
可在 [scriptable.config.js](./config.md#scriptable-config) 里配置 `inputFile` 以修改它的打包入口文件。 |
117 | | `npm run build:all` | 以生产环境打包多个文件 | `process.env.NODE_ENV = production`
默认混淆代码、加密代码
多文件打包入口默认是`./src/scripts/`
可在 [scriptable.config.js](./config.md#scriptable-config) 里配置 `inputDir` 以修改它的打包入口文件夹。 |
118 | | `npm run watch` | 以开发环境打包单个文件
并开启 server ,监听文件修改实时编译 | 在执行 `npm run dev` 的基础上,开启了本地 9090 端口的 server
server 会自动映射打包后的文件夹到 `http:// 127.0.0.1:9090/`
默认是 `./dist` 文件夹,可在 [scriptable.config.js](./config.md#scriptable-config) 的 `outputDir` 配置它。 |
119 |
120 | 所有打包输出文件夹默认都是 `./dist`,你可以在 [scriptable.config.js](./config.md#scriptable-config) 里配置 `outputDir` 以修改它的打包输出的文件夹
121 |
122 |
123 |
124 | ## 注意
125 |
126 | 由于打包器保留字 `module` 和 `Scriptable` 的全局变量 `module` 冲突,所以建议在访问 `Scriptable` `module` 变量时,改用 `MODULE` 访问。
127 |
128 |
--------------------------------------------------------------------------------
/docs/widget-element.md:
--------------------------------------------------------------------------------
1 | # JSX 组件
2 |
3 |
4 |
5 | ## wbox
6 |
7 | 盒子组件,必须处于 jsx 最外层,映射为 [ListWidget](https://docs.scriptable.app/listwidget/)
8 |
9 | | 属性 | 类型 | 默认值 | 必填 | 说明 |
10 | | ------------------------------------------------------------ | ---------------- | --------------------------------------- | ---- | ------------------------------------------------------------ |
11 | | [background](https://docs.scriptable.app/listwidget/#backgroundcolor) | string \| object | 浅色模式是#ffffff
暗黑模式是#000000 | 否 | 背景
可以为hex 字符串,例子:#ffffff
可以为网络图片链接,例子:http://example.com/a.jpg
可以为 [Color](https://docs.scriptable.app/color/) 对象
可以为 [Image](https://docs.scriptable.app/image/) 对象
可以为 [LinearGradient](https://docs.scriptable.app/lineargradient/) 渐变对象
wbox不能设置透明背景,ios不支持。
不过,可以通过[裁剪桌面截图,营造视觉上的透明](https://github.com/2214962083/ios-scriptable-tsx/blob/b528a7ceeed719f8cc7cd79bb1b422244c98cb91/src/lib/help.ts#L753) |
12 | | [spacing](https://docs.scriptable.app/listwidget/#spacing) | number | 0 | 否 | 间隔距离 |
13 | | [href](https://docs.scriptable.app/listwidget/#url) | string | | 否 | 点击时打开的url
不与 `onClick` 共存,当 `onClick` 存在时,只执行 `onClick` |
14 | | [updateDate](https://docs.scriptable.app/listwidget/#refreshafterdate) | object | | 否 | 小组件更新日期
只接收 [Date](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Date) 对象
该属性指示何时可以再次刷新窗口小部件。在到达日期之前,不会刷新小部件。**不保证小部件将在指定的日期完全刷新。**
小部件的刷新率部分取决于iOS / iPadOS。例如,如果设备电池电量低或用户很少看小部件,则小部件可能不会刷新。 |
15 | | [padding](https://docs.scriptable.app/listwidget/#-setpadding) | Array\ | [0,0,0,0] | 否 | 内边距,**依次是上、左、下、右**,四个都要填 |
16 | | onClick | function | | 否 | 用 [URLScheme](https://docs.scriptable.app/urlscheme/) 实现的点击事件
不与 `href` 共存,当 `href` 存在时,只执行 `onClick` |
17 |
18 |
19 |
20 |
21 |
22 | ## wstack
23 |
24 | 容器组件,类似 `div` ,映射为 [WidgetStack](https://docs.scriptable.app/widgetstack/)
25 |
26 | | 属性 | 类型 | 默认值 | 必填 | 说明 |
27 | | ------------------------------------------------------------ | ---------------- | --------- | ---- | ------------------------------------------------------------ |
28 | | [background](https://docs.scriptable.app/widgetstack/#backgroundcolor) | string \| object | | 否 | 背景
可以为hex 字符串,例子:#ffffff
可以为网络图片链接,例子:http://example.com/a.jpg
可以为 [Color](https://docs.scriptable.app/color/) 对象
可以为 [Image](https://docs.scriptable.app/image/) 对象
可以为 [LinearGradient](https://docs.scriptable.app/lineargradient/) 渐变对象 |
29 | | [spacing](https://docs.scriptable.app/widgetstack/#spacing) | number | 0 | 否 | 间隔距离 |
30 | | [padding](https://docs.scriptable.app/widgetstack/#-setpadding) | Array\ | [0,0,0,0] | 否 | 内边距,**依次是上、左、下、右**,四个都要填 |
31 | | [width](https://docs.scriptable.app/widgetstack/#size) | number | 0 | 否 | 组件宽,**当宽度设置 <= 0 时,小部件将自动确定该尺寸的长度。** |
32 | | [height](https://docs.scriptable.app/widgetstack/#size) | number | 0 | 否 | 组件高,**当高度设置 <= 0 时,小部件将自动确定该尺寸的长度。** |
33 | | [borderRadius](https://docs.scriptable.app/widgetstack/#cornerradius) | number | 0 | 否 | 边框四个角的圆角程度 |
34 | | [borderWidth](https://docs.scriptable.app/widgetstack/#borderwidth) | number | 0 | 否 | 边框宽度 |
35 | | [borderColor](https://docs.scriptable.app/widgetstack/#bordercolor) | string \| object | #000000 | 否 | 边框颜色
可以为hex 字符串,例子:#ffffff
可以为 [Color](https://docs.scriptable.app/color/) 对象 |
36 | | [href](https://docs.scriptable.app/widgetstack/#url) | string | | 否 | 点击时打开的url
不与 `onClick` 共存,当 `onClick` 存在时,只执行 `onClick`
**当小部件为小尺寸时,此属性不生效** |
37 | | [verticalAlign](#wstack-verticalAlign) | string | top | 否 | 内容垂直方向对齐方式 |
38 | | [flexDirection](#wstack-flexDirection) | string | row | 否 | 排版方向 |
39 | | onClick | function | | 否 | 用 [URLScheme](https://docs.scriptable.app/urlscheme/) 实现的点击事件
不与 `href` 共存,当 `href` 存在时,只执行 `onClick`
**当小部件为小尺寸时,此属性不生效** |
40 |
41 |
42 | ##### verticalAlign 的合法值
43 |
44 | | 值 | 说明 |
45 | | ------------------------------------------------------------ | -------------------- |
46 | | [top](https://docs.scriptable.app/widgetstack/#-topaligncontent) | 顶部对齐内容(默认) |
47 | | [center](https://docs.scriptable.app/widgetstack/#-centeraligncontent) | 居中对齐内容 |
48 | | [bottom](https://docs.scriptable.app/widgetstack/#-bottomaligncontent) | 底部对齐内容 |
49 |
50 |
51 | ##### flexDirection 的合法值
52 |
53 | | 值 | 说明 |
54 | | ------------------------------------------------------------ | ---------------- |
55 | | [row](https://docs.scriptable.app/widgetstack/#-layouthorizontally) | 横向排版(默认) |
56 | | [column](https://docs.scriptable.app/widgetstack/#-layoutvertically) | 纵向排版 |
57 |
58 |
59 |
60 |
61 |
62 | ## wimage
63 |
64 | 图片组件 ,映射为 [WidgetImage](https://docs.scriptable.app/widgetimage/) ,组件里面不可包裹其他组件
65 |
66 | | 属性 | 类型 | 默认值 | 必填 | 说明 |
67 | | ------------------------------------------------------------ | ---------------- | ------- | ---- | ------------------------------------------------------------ |
68 | | [src](https://docs.scriptable.app/widgetimage/#image) | string \| object | | 是 | 图片资源地址
可以为网络连接,例子:http://example.com/a.jpg
可以为 [Image](https://docs.scriptable.app/image/) 对象
可以为 [SFSymbol 的 icon名字](https://docs.scriptable.app/sfsymbol/) ,就是 [ios 自带图标库里](https://apps.apple.com/us/app/sf-symbols-browser/id1491161336) 某图标的iconName,例子:tv.circle.fill
|
69 | | [href](https://docs.scriptable.app/widgetimage/#url) | string | | 否 | 点击时打开的url
不与 `onClick` 共存,当 `onClick` 存在时,只执行 `onClick`
**当小部件为小尺寸时,此属性不生效** |
70 | | [resizable](https://docs.scriptable.app/widgetimage/#resizable) | boolean | true | 否 | 图片是否可以调整大小 |
71 | | [width](https://docs.scriptable.app/widgetimage/#imagesize) | number | | 否 | 图片宽,**当宽高都为空时,图片将显示原尺寸。** |
72 | | [height](https://docs.scriptable.app/widgetimage/#imagesize) | number | | 否 | 图片高,**当宽高都为空时,图片将显示原尺寸。** |
73 | | [opacity](https://docs.scriptable.app/widgetimage/#imageopacity) | number | 1 | 否 | 透明度,范围0到1,0为完全透明,1为完全不透明。 |
74 | | [borderRadius](https://docs.scriptable.app/widgetimage/#cornerradius) | number | 0 | 否 | 边框四个角的圆角程度 |
75 | | [borderWidth](https://docs.scriptable.app/widgetimage/#borderwidth) | number | 0 | 否 | 边框宽度 |
76 | | [borderColor](https://docs.scriptable.app/widgetimage/#bordercolor) | string \| object | #000000 | 否 | 边框颜色
可以为hex 字符串,例子:#ffffff
可以为 [Color](https://docs.scriptable.app/color/) 对象 |
77 | | [containerRelativeShape](https://docs.scriptable.app/widgetimage/#containerrelativeshape) | boolean | false | 否 | 如果为true,则图片的角将相对于包含的小部件进行四舍五入。
如果为true,则会忽略borderRadius的值
我知道你看不懂,我也是,可以[看官方文档解释](https://docs.scriptable.app/widgetimage/#containerrelativeshape) |
78 | | [filter](https://docs.scriptable.app/widgetimage/#tintcolor) | string \| object | | 否 | 加滤镜
可以为hex 字符串,例子:#ffffff
可以为 [Color](https://docs.scriptable.app/color/) 对象 |
79 | | [imageAlign](#wimage-imageAlign) | string | left | | 图片横向对齐方式 |
80 | | [mode](#wimage-mode) | string | fit | 否 | 图片显示模式 |
81 | | onClick | function | | 否 | 用 [URLScheme](https://docs.scriptable.app/urlscheme/) 实现的点击事件
不与 `href` 共存,当 `href` 存在时,只执行 `onClick`
**当小部件为小尺寸时,此属性不生效** |
82 |
83 |
84 |
85 | ##### imageAlign 的合法值
86 |
87 | | 值 | 说明 |
88 | | ------------------------------------------------------------ | ------------------ |
89 | | [left](https://docs.scriptable.app/widgetimage/#-leftalignimage) | 左对齐图片(默认) |
90 | | [center](https://docs.scriptable.app/widgetimage/#-centeralignimage) | 中间对齐图片 |
91 | | [right](https://docs.scriptable.app/widgetimage/#-rightalignimage) | 右对齐图片 |
92 |
93 |
94 |
95 | ##### mode 的合法值
96 |
97 | | 值 | 说明 |
98 | | ------------------------------------------------------------ | -------------------------- |
99 | | [fit](https://docs.scriptable.app/widgetimage/#-applyfittingcontentmode) | 图片将适应可用空间(默认) |
100 | | [fill](https://docs.scriptable.app/widgetimage/#-applyfillingcontentmode) | 图片将填充可用空间 |
101 |
102 |
103 |
104 |
105 |
106 | ## wspacer
107 |
108 | 空格占位组件,映射为 [WidgetSpacer](https://docs.scriptable.app/widgetspacer/) ,组件里面不可包裹其他组件
109 |
110 | | 属性 | 类型 | 默认值 | 必填 | 说明 |
111 | | ---------------------------------------------------------- | ------ | ------ | ---- | --------------------------------------- |
112 | | [length](https://docs.scriptable.app/widgetspacer/#length) | number | 0 | 否 | 空格长度,当为0时是弹性占位(能占则占) |
113 |
114 |
115 |
116 |
117 |
118 | ## wtext
119 |
120 | 文字组件,映射为 [WidgetText](https://docs.scriptable.app/widgettext/) ,组件里面不可包裹其他组件,只可以包裹文字
121 |
122 | | 属性 | 类型 | 默认值 | 必填 | 说明 |
123 | | ------------------------------------------------------------ | ---------------- | --------------------------------------- | ---- | ------------------------------------------------------------ |
124 | | [textColor](https://docs.scriptable.app/widgettext/#textcolor) | string \| object | 浅色模式是#000000
暗黑模式是#ffffff | 否 | 文字颜色
可以为hex 字符串,例子:#ffffff
可以为 [Color](https://docs.scriptable.app/color/) 对象 |
125 | | [font](https://docs.scriptable.app/widgettext/#font) | number \| object | | 否 | 文字字体样式
为number时,使用正常系统字体,并设置为这个尺寸。
也可以传 [Font](https://docs.scriptable.app/font/) 字体对象进来 |
126 | | [opacity](https://docs.scriptable.app/widgettext/#textopacity) | number | 1 | 否 | 透明度,范围0到1,0为完全透明,1为完全不透明。 |
127 | | [maxLine](https://docs.scriptable.app/widgettext/#linelimit) | number | 0 | 否 | 最大行数。
显示的最大行数。该值 <= 0 时,将禁用该限制。 |
128 | | [scale](https://docs.scriptable.app/widgettext/#minimumscalefactor) | number | 1 | 否 | 文字可缩最小的倍数,取值范围 0 到 1 。
例如:0.5 时,允许布局调整时缩小文字到原来的 0.5 倍大小
有点拗口,[看官方文档](https://docs.scriptable.app/widgettext/#minimumscalefactor) |
129 | | [shadowColor](https://docs.scriptable.app/widgettext/#shadowcolor) | string \| object | #000000 | 否 | 阴影颜色, `shadowRadius` 属性的值必须大于零,此属性才能生效。
可以为hex 字符串,例子:#ffffff
可以为 [Color](https://docs.scriptable.app/color/) 对象 |
130 | | [shadowRadius](https://docs.scriptable.app/widgettext/#shadowradius) | number | 0 | 否 | 阴影模糊距离 |
131 | | [shadowOffset](https://docs.scriptable.app/widgettext/#shadowoffset) | object | new Point(0, 0) | 否 | 阴影的偏移位置。 `shadowRadius` 属性的值必须大于零,此属性才能生效。
传入 [Point](https://docs.scriptable.app/point/#point) 位置对象 |
132 | | [href](https://docs.scriptable.app/widgettext/#url) | string | | 否 | 点击时打开的url
不与 `onClick` 共存,当 `onClick` 存在时,只执行 `onClick`
**当小部件为小尺寸时,此属性不生效** |
133 | | [textAlign](#wtext-textAlign) | string | left | 否 | |
134 | | onClick | function | | 否 | 用 [URLScheme](https://docs.scriptable.app/urlscheme/) 实现的点击事件
不与 `href` 共存,当 `href` 存在时,只执行 `onClick`
**当小部件为小尺寸时,此属性不生效** |
135 |
136 |
137 |
138 | ##### textAlign 的合法值
139 |
140 | | 值 | 说明 |
141 | | ------------------------------------------------------------ | ------------------------------------------------------------ |
142 | | [left](https://docs.scriptable.app/widgettext/#-leftaligntext) | 文字左对齐(在`wstack`里左对齐文本,应该在文本组件右边放一个 `wspacer` ) |
143 | | [center](https://docs.scriptable.app/widgettext/#-centeraligntext) | 文字居中对齐(在`wstack`里居中对齐文本,应该在文本组件两边放一个 `wspacer` ) |
144 | | [right](https://docs.scriptable.app/widgettext/#-rightaligntext) | 文字右对齐(在`wstack`里右对齐文本,应该在文本组件左边放一个 `wspacer` ) |
145 |
146 |
147 |
148 |
149 |
150 | ## wdate
151 |
152 | 日期组件,映射为 [WidgetDate](https://docs.scriptable.app/widgetdate/) ,组件里面不可包裹其他组件,有点类似 `wtext` 组件,区别在文字改成了日期
153 |
154 | | 属性 | 类型 | 默认值 | 必填 | 说明 |
155 | | ------------------------------------------------------------ | ---------------- | --------------------------------------- | ---- | ------------------------------------------------------------ |
156 | | [date](https://docs.scriptable.app/widgetdate/#date) | object | | 是 | 需要显示的时间
只接收 [Date](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Date) 对象 |
157 | | [mode](#wdate-mode) | string | | 是 | 显示的时间格式 |
158 | | [textColor](https://docs.scriptable.app/widgetdate/#textcolor) | string \| object | 浅色模式是#000000
暗黑模式是#ffffff | 否 | 文字颜色
可以为hex 字符串,例子:#ffffff
可以为 [Color](https://docs.scriptable.app/color/) 对象 |
159 | | [font](https://docs.scriptable.app/widgetdate/#font) | number \| object | | 否 | 文字字体样式
为number时,使用正常系统字体,并设置为这个尺寸。
也可以传 [Font](https://docs.scriptable.app/font/) 字体对象进来 |
160 | | [opacity](https://docs.scriptable.app/widgetdate/#textopacity) | number | 1 | 否 | 透明度,范围0到1,0为完全透明,1为完全不透明。 |
161 | | [maxLine](https://docs.scriptable.app/widgetdate/#linelimit) | number | 0 | 否 | 最大行数。
显示的最大行数。该值 <= 0 时,将禁用该限制。 |
162 | | [scale](https://docs.scriptable.app/widgetdate/#minimumscalefactor) | number | 1 | 否 | 文字可缩最小的倍数,取值范围 0 到 1 。
例如:0.5 时,允许布局调整时缩小文字到原来的 0.5 倍大小
有点拗口,[看官方文档](https://docs.scriptable.app/widgettext/#minimumscalefactor) |
163 | | [shadowColor](https://docs.scriptable.app/widgetdate/#shadowcolor) | string \| object | #000000 | 否 | 阴影颜色, `shadowRadius` 属性的值必须大于零,此属性才能生效。
可以为hex 字符串,例子:#ffffff
可以为 [Color](https://docs.scriptable.app/color/) 对象 |
164 | | [shadowRadius](https://docs.scriptable.app/widgetdate/#shadowradius) | number | 0 | 否 | 阴影模糊距离 |
165 | | [shadowOffset](https://docs.scriptable.app/widgetdate/#shadowoffset) | object | new Point(0, 0) | 否 | 阴影的偏移位置。 `shadowRadius` 属性的值必须大于零,此属性才能生效。
传入 [Point](https://docs.scriptable.app/point/#point) 位置对象 |
166 | | [href](https://docs.scriptable.app/widgetdate/#url) | string | | 否 | 点击时打开的url
不与 `onClick` 共存,当 `onClick` 存在时,只执行 `onClick`
**当小部件为小尺寸时,此属性不生效** |
167 | | [textAlign](#wtext-textAlign) | string | left | 否 | |
168 | | onClick | function | | 否 | 用 [URLScheme](https://docs.scriptable.app/urlscheme/) 实现的点击事件
不与 `href` 共存,当 `href` 存在时,只执行 `onClick`
**当小部件为小尺寸时,此属性不生效** |
169 |
170 |
171 |
172 | ##### mode 的合法值
173 |
174 | | 值 | 说明 |
175 | | ------------------------------------------------------------ | ------------------------------------------------------------ |
176 | | [time](https://docs.scriptable.app/widgetdate/#-applytimestyle) | 显示日期的时间部分。例如:11:23PM |
177 | | [date](https://docs.scriptable.app/widgetdate/#-applydatestyle) | 显示整个日期。例如:June 3, 2019 |
178 | | [relative](https://docs.scriptable.app/widgetdate/#-applyrelativestyle) | 将日期显示为相对于现在的日期。例如:2 hours, 23 minutes 1 year, 1 month |
179 | | [offset](https://docs.scriptable.app/widgetdate/#-applyoffsetstyle) | 将日期显示为从现在开始的偏移量。例如:+2 hours -3 months |
180 | | [timer](https://docs.scriptable.app/widgetdate/#-applytimerstyle) | 从现在开始将日期显示为计时器计数。例如:2:32 36:59:01 |
181 |
182 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignore": ["src/**/*.spec.ts"],
3 | "watch": ["src/"],
4 | "ext": "ts,tsx,json,js",
5 | "exec": "node -e \"console.clear()\"&& npm run dev"
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "scriptable-types",
3 | "version": "1.0.0",
4 | "author": "xiaomingyang",
5 | "description": "",
6 | "main": "index.js",
7 | "keywords": [
8 | "scriptable",
9 | "ios",
10 | "widget"
11 | ],
12 | "scripts": {
13 | "watch": "cross-env watching=true nodemon --config nodemon.json",
14 | "dev": "cross-env NODE_ENV=development compileType=one ts-node ./src/lib/compile.ts",
15 | "dev:all": "cross-env NODE_ENV=development compileType=all ts-node ./src/lib/compile.ts",
16 | "build": "cross-env NODE_ENV=production compileType=one ts-node ./src/lib/compile.ts",
17 | "build:all": "cross-env NODE_ENV=production compileType=all ts-node ./src/lib/compile.ts"
18 | },
19 | "license": "ISC",
20 | "devDependencies": {
21 | "@types/dotenv": "^8.2.0",
22 | "@types/eslint": "^7.2.2",
23 | "@types/express": "^4.17.9",
24 | "@types/fs-extra": "^9.0.5",
25 | "@types/ip": "^1.1.0",
26 | "@types/lodash": "^4.14.165",
27 | "@types/node": "^14.6.0",
28 | "@types/prettier": "^2.1.1",
29 | "@types/react": "^17.0.0",
30 | "@types/react-dom": "^17.0.0",
31 | "@types/scriptable-ios": "^1.6.1",
32 | "@typescript-eslint/eslint-plugin": "^4.1.1",
33 | "@typescript-eslint/parser": "^4.1.1",
34 | "cross-env": "^7.0.3",
35 | "esbuild": "^0.8.12",
36 | "eslint": "^7.9.0",
37 | "eslint-config-prettier": "^6.11.0",
38 | "eslint-plugin-prettier": "^3.1.4",
39 | "javascript-obfuscator": "^2.9.4",
40 | "nodemon": "^2.0.6",
41 | "prettier": "^2.1.1",
42 | "ts-loader": "^8.0.2",
43 | "ts-node": "^8.10.2",
44 | "typescript": "^4.0.2"
45 | },
46 | "dependencies": {
47 | "body-parser": "^1.19.0",
48 | "chalk": "^4.1.0",
49 | "dotenv": "^8.2.0",
50 | "dotenv-expand": "^5.1.0",
51 | "express": "^4.17.1",
52 | "fs-extra": "^9.0.1",
53 | "ip": "^1.1.5",
54 | "lodash": "^4.17.20",
55 | "qrcode-terminal": "^0.12.0"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | module.exports = /** @type { import ('prettier').RequiredOptions } */ ({
4 | printWidth: 120,
5 | semi: false,
6 | singleQuote: true,
7 | trailingComma: 'all',
8 | bracketSpacing: false,
9 | arrowParens: 'avoid',
10 | insertPragma: false,
11 | tabWidth: 2,
12 | useTabs: false,
13 | endOfLine: 'auto',
14 | })
15 |
--------------------------------------------------------------------------------
/scriptable.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | const path = require('path')
4 |
5 | /**项目根目录,不建议修改*/
6 | const rootPath = __dirname
7 |
8 | /**输入文件,当 compileType 为 one 时生效,不建议修改*/
9 | const inputFile = path.resolve(rootPath, './src/index.ts')
10 |
11 | /**输入文件夹,当 compileType 为 all 时生效,不建议修改*/
12 | const inputDir = path.resolve(rootPath, './src/scripts')
13 |
14 | /**输出文件夹,不建议修改*/
15 | const outputDir = path.resolve(rootPath, './dist')
16 |
17 | /**是否压缩代码*/
18 | const minify = process.env.NODE_ENV === 'production'
19 |
20 | /**是否加密代码*/
21 | const encrypt = process.env.NODE_ENV === 'production'
22 |
23 | /**往编译后的代码头部插入的代码*/
24 | const header = `
25 | /**
26 | * 作者: 小明
27 | * 版本: 1.0.0
28 | * 更新时间:${new Date().toLocaleDateString()}
29 | * github: https://github.com/2214962083/ios-scriptable-tsx
30 | */
31 | `
32 |
33 | module.exports = /** @type { import ('./src/lib/compile').CompileOptions } */ ({
34 | rootPath,
35 | inputFile,
36 | inputDir,
37 | outputDir,
38 | minify,
39 | encrypt,
40 | header,
41 |
42 | /**
43 | * esbuild 自定义配置
44 | * see: https://esbuild.github.io/api/#simple-options
45 | */
46 | esbuild: {},
47 |
48 | /**
49 | * javascript-obfuscator 自定义配置
50 | * see: https://github.com/javascript-obfuscator/javascript-obfuscator
51 | */
52 | encryptOptions: {},
53 | })
54 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import './scripts/helloWorld.tsx'
2 |
--------------------------------------------------------------------------------
/src/lib/basic.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 基础包源码
3 | */
4 | import {port} from '@app/lib/constants'
5 | import {useStorage, showActionSheet, showModal, showNotification, sleep, getSciptableTopComment} from '@app/lib/help'
6 |
7 | interface DevelopRemoteParams {
8 | /**被同步的脚本的路径*/
9 | syncScriptName?: string | null
10 |
11 | /**被同步的脚本的文件名*/
12 | syncScriptPath?: string | null
13 |
14 | /**远程文件地址*/
15 | remoteFileAddress?: string | null
16 | }
17 |
18 | const {setStorage, getStorage} = useStorage('basic-storage')
19 | const runScriptDate = Date.now()
20 | setStorage('runScriptDate', runScriptDate)
21 |
22 | class Basic {
23 | /**间隔多久同步一次脚本,单位:毫秒*/
24 | private syncInterval = 1 * 1000
25 |
26 | /**本地脚本编译时间*/
27 | private lastCompileDate = 0
28 |
29 | /**请求超时,单位:毫秒*/
30 | private timeout = 5 * 1000
31 |
32 | /**已连续请求失败次数*/
33 | private requestFailTimes = 0
34 |
35 | /**连续请求失败最大次数,单位:毫秒*/
36 | private maxRequestFailTimes = 10
37 |
38 | async init() {
39 | await this.showMenu()
40 | }
41 | /**获取本地脚本列表,name 是脚本名字,path 是脚本路径*/
42 | getLocalScripts(): Record<'name' | 'path', string>[] {
43 | const dirPath = MODULE.filename.split('/').slice(0, -1).join('/')
44 | let scriptNames: string[] = FileManager.local().listContents(dirPath) || []
45 | // 过滤非.js结尾的文件
46 | scriptNames = scriptNames.filter(scriptName => /\.js$/.test(scriptName))
47 | return scriptNames.map(scriptName => ({
48 | name: scriptName,
49 | path: FileManager.local().joinPath(dirPath, scriptName),
50 | }))
51 | }
52 | /**
53 | * 请求获取脚本内容
54 | * @param url 远程文件地址
55 | */
56 | async getScriptText(url: string): Promise {
57 | try {
58 | const req = new Request(url)
59 | req.timeoutInterval = this.timeout / 1000
60 | const res = await req.loadString()
61 | this.requestFailTimes = 0
62 | return res || ''
63 | } catch (err) {
64 | // 如果失败时间戳为 null,则记录本次失败时间
65 | this.requestFailTimes += 1
66 | return ''
67 | }
68 | }
69 | /**显示菜单*/
70 | async showMenu() {
71 | const that = this
72 | let itemList = ['远程开发']
73 | const syncScriptName = getStorage('syncScriptName')
74 | const syncScriptPath = getStorage('syncScriptPath')
75 | const remoteFileAddress = getStorage('remoteFileAddress')
76 | const scriptText = getStorage('scriptText')
77 |
78 | if (syncScriptName && syncScriptPath && remoteFileAddress) {
79 | // 如果上次有远程开发过,则加入直接同步选项
80 | itemList = ['远程开发', `同步${syncScriptName}`]
81 |
82 | if (scriptText) {
83 | itemList.push(`运行缓存里的${syncScriptName}`)
84 | }
85 | }
86 | const selectIndex = await showActionSheet({
87 | itemList,
88 | })
89 | switch (selectIndex) {
90 | case 0:
91 | await that.developRemote()
92 | break
93 | case 1:
94 | await that.developRemote({
95 | syncScriptName,
96 | syncScriptPath,
97 | remoteFileAddress,
98 | })
99 | break
100 | case 2:
101 | // 执行远程代码
102 | await that.runCode(syncScriptName as string, scriptText as string)
103 | break
104 | }
105 | }
106 | /**远程开发同步*/
107 | async developRemote(params: DevelopRemoteParams = {}): Promise {
108 | const that = this
109 |
110 | /**被同步的脚本的路径*/
111 | let _syncScriptPath = params.syncScriptPath
112 |
113 | /**被同步的脚本的文件名*/
114 | let _syncScriptName = params.syncScriptName
115 |
116 | /**远程api*/
117 | let _remoteFileAddress = params.remoteFileAddress
118 |
119 | if (!_syncScriptPath || !_syncScriptName) {
120 | // 选择要开发的脚本
121 | const scripts = that.getLocalScripts()
122 | const selectIndex = await showActionSheet({
123 | title: '选择你要开发的脚本',
124 | itemList: scripts.map(script => script.name),
125 | })
126 | if (selectIndex < 0) return
127 |
128 | /**被同步的脚本的路径*/
129 | _syncScriptPath = scripts[selectIndex].path
130 | setStorage('syncScriptPath', _syncScriptPath)
131 |
132 | /**被同步的脚本的文件名*/
133 | _syncScriptName = scripts[selectIndex].name
134 | setStorage('syncScriptName', _syncScriptName)
135 | }
136 |
137 | if (!_remoteFileAddress) {
138 | /**内存中的远程文件地址*/
139 | _remoteFileAddress = getStorage('remoteFileAddress') || ''
140 |
141 | // 输入远程文件地址
142 | const {cancel, texts} = await showModal({
143 | title: '远程文件地址',
144 | content: '请输入远程开发服务器(电脑)要同步的文件地址',
145 | confirmText: '连接',
146 | inputItems: [
147 | {
148 | placeholder: '输入远程文件地址',
149 | text: _remoteFileAddress || `http://192.168.1.3:${port}/index.js`,
150 | },
151 | ],
152 | })
153 | if (cancel) return
154 |
155 | /**远程文件地址*/
156 | _remoteFileAddress = texts[0]
157 |
158 | if (!_remoteFileAddress) return
159 |
160 | setStorage('remoteFileAddress', _remoteFileAddress)
161 | }
162 |
163 | if (!_remoteFileAddress || !_syncScriptName || !_syncScriptPath) {
164 | await showNotification({
165 | title: '信息不完整,运行终止',
166 | body: '没选择脚本或远程ip没填写',
167 | sound: 'failure',
168 | })
169 | return
170 | }
171 |
172 | await showNotification({title: '开始同步代码'})
173 |
174 | /**同步脚本*/
175 | const syncScript = async () => {
176 | /**远程脚本字符串*/
177 | let scriptText = await that.getScriptText(_remoteFileAddress as string)
178 |
179 | /**匹配时间戳,例子:'// @编译时间 1606834773399' */
180 | const compileDateRegExp = /\/\/\s*?\@编译时间\s*?([\d]+)/
181 |
182 | /**匹配结果*/
183 | const dateMatchResult = scriptText.match(compileDateRegExp)
184 |
185 | /**本次远程脚本的编译时间*/
186 | const thisCompileDate = Number(dateMatchResult && dateMatchResult[1]) || null
187 |
188 | // 如果没有获取到脚本内容、此次脚本编译时间为空,则不写入
189 | if (!scriptText || !thisCompileDate) return
190 |
191 | //如果此次脚本编译时间和上次相同,则不写入
192 | if (thisCompileDate && thisCompileDate <= that.lastCompileDate) return
193 |
194 | try {
195 | // 写入代码到文件
196 | const comment = getSciptableTopComment(_syncScriptPath as string)
197 | await FileManager.local().writeString(_syncScriptPath as string, `${comment}\n${scriptText}`)
198 |
199 | // 添加 console 重写代码
200 | const serverApi = (_remoteFileAddress?.match(/http\:\/\/[\d\.]+?\:[\d]+/) || [])[0]
201 | scriptText = `${that.getRewriteConsoleCode(serverApi)}\n${scriptText}`
202 |
203 | // 写入代码到缓存
204 | setStorage('scriptText', scriptText)
205 |
206 | that.lastCompileDate = thisCompileDate
207 | await showNotification({title: '同步代码成功'})
208 |
209 | // 执行远程代码
210 | await that.runCode(_syncScriptName as string, scriptText)
211 | } catch (err) {
212 | console.log('代码写入失败')
213 | console.log(err)
214 | await showNotification({
215 | title: '代码同步失败',
216 | body: err.message,
217 | sound: 'failure',
218 | })
219 | }
220 | }
221 |
222 | // 循环执行
223 | while (1) {
224 | const _runScriptDate = getStorage('runScriptDate')
225 | if (runScriptDate !== Number(_runScriptDate)) {
226 | // 本脚本重新运行了,停止这次同步
227 | break
228 | }
229 | if (this.requestFailTimes >= this.maxRequestFailTimes) {
230 | // 太久请求不成功了,终结远程同步
231 | await showNotification({
232 | title: '已停止同步',
233 | subtitle: '远程开发已停止,连接超时。',
234 | sound: 'complete',
235 | })
236 | break
237 | }
238 | await syncScript()
239 | await sleep(that.syncInterval)
240 | }
241 | }
242 | /**
243 | * 执行远程代码
244 | * @param syncScriptName 脚本名称
245 | * @param scriptText 脚本内容
246 | */
247 | async runCode(syncScriptName: string, scriptText: string) {
248 | try {
249 | const runRemoteCode = new Function(`(async () => {
250 | ${scriptText}
251 | })()`)
252 | // 执行远程代码
253 | runRemoteCode()
254 | } catch (err) {
255 | console.log('同步的代码执行失败')
256 | console.log(err)
257 | await showNotification({
258 | title: `${syncScriptName}执行失败`,
259 | body: err.message,
260 | sound: 'failure',
261 | })
262 | }
263 | }
264 | /**
265 | * 获取重写 console 的方法
266 | * @param serverApi 远程链接api地址,如 http://192.168.2.4:9090
267 | */
268 | getRewriteConsoleCode(serverApi: string) {
269 | return `
270 | // 保留日志原始打印方法
271 | const __log__ = console.log;
272 | const __warn__ = console.warn;
273 | const __error__ = console.error;
274 |
275 | /**发到日志远程控制台*/
276 | const __sendLogToRemote__ = async (type = 'log', data = '') => {
277 | const req = new Request('${serverApi}/console');
278 | req.method = 'POST';
279 | req.headers = {
280 | 'Content-Type': 'application/json',
281 | };
282 | req.body = JSON.stringify({
283 | type,
284 | data,
285 | });
286 | return await req.loadJSON()
287 | }
288 |
289 | /**存储上个console 的promise*/
290 | let __lastConsole__ = Promise.resolve()
291 |
292 | /**重写生成日志函数*/
293 | const __generateLog__ = (type = 'log', oldFunc) => {
294 | return function(...args) {
295 | /**为了同步打印,finally 兼容性太差*/
296 | __lastConsole__.then(() => {
297 | __lastConsole__ = __sendLogToRemote__(type, args[0]).catch(err => {})
298 | }).catch(() => {
299 | __lastConsole__ = __sendLogToRemote__(type, args[0]).catch(err => {})
300 | })
301 | oldFunc.apply(this, args);
302 | }
303 | };
304 | if (!console.__rewrite__) {
305 | console.log = __generateLog__('log', __log__).bind(console);
306 | console.warn = __generateLog__('warn', __warn__).bind(console);
307 | console.error = __generateLog__('error', __error__).bind(console);
308 | }
309 | console.__rewrite__ = true;
310 | `
311 | }
312 | }
313 |
314 | new Basic().init()
315 |
--------------------------------------------------------------------------------
/src/lib/compile.ts:
--------------------------------------------------------------------------------
1 | import {build, BuildOptions, OutputFile} from 'esbuild'
2 | import fs from 'fs'
3 | import path from 'path'
4 | import {promisify} from 'util'
5 | import {merge} from 'lodash'
6 | import {obfuscate, ObfuscatorOptions} from 'javascript-obfuscator'
7 | import {createServer} from './server'
8 | import {loadEnvFiles} from './env'
9 | import compileOptions from '../../scriptable.config'
10 | import {ensureFile, remove} from 'fs-extra'
11 |
12 | /**打包模式*/
13 | export enum CompileType {
14 | /**打包一个文件夹*/
15 | ALL = 'all',
16 |
17 | /**为打包入口文件*/
18 | ONE = 'one',
19 | }
20 |
21 | export interface CompileOptions {
22 | /**项目根目录*/
23 | rootPath: string
24 |
25 | /**输入文件,当 compileType 为 one 时生效*/
26 | inputFile: string
27 |
28 | /**输入文件夹,当 compileType 为 all 时生效*/
29 | inputDir: string
30 |
31 | /**输出文件夹*/
32 | outputDir: string
33 |
34 | /**打包模式,all 为打包一个文件夹,one为打包入口文件*/
35 | compileType?: CompileType
36 |
37 | /**是否在 watch 开发*/
38 | watch?: boolean
39 |
40 | /**是否显示二维码*/
41 | showQrcode?: boolean
42 |
43 | /**
44 | * esbuild 自定义配置
45 | * see: https://esbuild.github.io/api/#simple-options
46 | */
47 | esbuild?: BuildOptions
48 |
49 | /**是否压缩代码*/
50 | minify?: boolean
51 |
52 | /**在编译中添加额外的头部,一般是作者信息*/
53 | header?: string
54 |
55 | /**是否加密代码*/
56 | encrypt?: boolean
57 |
58 | /**
59 | * javascript-obfuscator 自定义配置
60 | * see: https://github.com/javascript-obfuscator/javascript-obfuscator
61 | */
62 | encryptOptions?: ObfuscatorOptions
63 | }
64 |
65 | /**项目根目录*/
66 | const rootPath = path.resolve(__dirname, '../')
67 |
68 | /**输入文件,当 compileType 为 one 时生效*/
69 | const inputFile: string = path.resolve(rootPath, './src/index.ts')
70 |
71 | /**输入文件夹,当 compileType 为 all 时生效*/
72 | const inputDir: string = path.resolve(rootPath, './src/scripts')
73 |
74 | /**输出文件夹*/
75 | const outputDir: string = path.resolve(rootPath, './dist')
76 |
77 | /**打包模式,all 为打包一个文件夹,one为打包入口文件*/
78 | const compileType = (process.env.compileType as CompileType) || CompileType.ONE
79 |
80 | /**是否在 watch 开发*/
81 | const watch = Boolean(process.env.watching)
82 |
83 | /**是否压缩代码*/
84 | const minify = process.env.NODE_ENV === 'production'
85 |
86 | /**是否加密代码*/
87 | const encrypt = process.env.NODE_ENV === 'production'
88 |
89 | const _compileOptions = {
90 | rootPath,
91 | inputFile,
92 | inputDir,
93 | outputDir,
94 | compileType,
95 | watch,
96 | minify,
97 | encrypt,
98 | }
99 |
100 | compile(merge(_compileOptions, compileOptions || {}))
101 |
102 | async function compile(options: CompileOptions) {
103 | const {
104 | rootPath,
105 | inputDir,
106 | inputFile,
107 | outputDir,
108 | compileType = CompileType.ONE,
109 | watch = false,
110 | showQrcode = true,
111 | esbuild = {},
112 | minify = false,
113 | encrypt = false,
114 | encryptOptions = {},
115 | header = '',
116 | } = options
117 |
118 | /**加载环境变量 .env 文件*/
119 | loadEnvFiles(rootPath)
120 |
121 | if (watch) {
122 | /**创建服务器*/
123 | createServer({
124 | staticDir: outputDir,
125 | showQrcode,
126 | })
127 | }
128 |
129 | // 编译时,把 process.env 环境变量替换成 dotenv 文件参数
130 | const define: Record = {}
131 | for (const key in process.env) {
132 | // 不能含有括号、-号、空格
133 | if (/[\(\)\-\s]/.test(key)) continue
134 | define[`process.env.${key}`] = JSON.stringify(process.env[key])
135 | }
136 |
137 | // 深度获取某个文件夹里所有文件路径(包括子文件夹)
138 | const readdir = promisify(fs.readdir)
139 | const stat = promisify(fs.stat)
140 | async function getFilesFromDir(dir: string): Promise {
141 | const subdirs = await readdir(dir)
142 | const files = await Promise.all(
143 | subdirs.map(async subdir => {
144 | const res = path.resolve(dir, subdir)
145 | return (await stat(res)).isDirectory() ? getFilesFromDir(res) : res
146 | }),
147 | )
148 | return files.reduce((a: string[], f: string | string[]) => a.concat(f), [])
149 | }
150 |
151 | /**所有输出的文件信息集合*/
152 | let outputFilesInfo: OutputFile[] = []
153 |
154 | try {
155 | /**计算输入文件路径集合*/
156 | const inputPaths: string[] = compileType === CompileType.ALL ? await getFilesFromDir(inputDir) : [inputFile]
157 |
158 | /** esbuild 配置*/
159 | const esbuildOptions: BuildOptions = {
160 | entryPoints: [...inputPaths],
161 | platform: 'node',
162 | charset: 'utf8',
163 | bundle: true,
164 | outdir: outputDir,
165 | banner: `${header}
166 | // @编译时间 ${Date.now()}
167 | const MODULE = module;
168 | let __topLevelAwait__ = () => Promise.resolve();
169 | function EndAwait(promiseFunc) {
170 | __topLevelAwait__ = promiseFunc
171 | };
172 | `,
173 | footer: `
174 | await __topLevelAwait__();
175 | `,
176 | jsxFactory: 'h',
177 | jsxFragment: 'Fragment',
178 | define,
179 | minify,
180 | write: false,
181 | inject: [path.resolve(rootPath, './src/lib/jsx-runtime.ts')],
182 | }
183 |
184 | // 最终打包环节
185 | // 先清空输出文件夹
186 | await remove(outputDir)
187 |
188 | // esbuild 打包
189 | outputFilesInfo = (await build(merge(esbuildOptions, esbuild))).outputFiles || []
190 | console.error('esbuild打包结束')
191 | } catch (err) {
192 | console.error('esbuild打包出错', err)
193 | process.exit(1)
194 | }
195 |
196 | const writeFile = promisify(fs.writeFile)
197 | for (const outputFile of outputFilesInfo) {
198 | let writeText = outputFile.text
199 | if (encrypt) {
200 | // 加密环节
201 | try {
202 | /**加密配置*/
203 | const _encryptOptions: ObfuscatorOptions = {
204 | rotateStringArray: true,
205 | selfDefending: true,
206 | stringArray: true,
207 | splitStringsChunkLength: 100,
208 | stringArrayEncoding: ['rc4', 'base64'],
209 | }
210 | // 读取失败就跳下一轮
211 | if (!outputFile.text) continue
212 |
213 | // 加密代码
214 | const transformCode = obfuscate(outputFile.text, merge(_encryptOptions, encryptOptions)).getObfuscatedCode()
215 |
216 | // 写入加入代码、和头部信息
217 | writeText = `${header}\n${transformCode}`
218 | } catch (err) {
219 | console.error('加密代码失败', err)
220 | process.exit(1)
221 | }
222 | }
223 | // 确保路径存在
224 | await ensureFile(outputFile.path)
225 | // 写入代码
226 | await writeFile(outputFile.path, writeText, {encoding: 'utf8'})
227 | }
228 |
229 | console.log('加密代码结束')
230 | console.log('打包完成')
231 | }
232 |
--------------------------------------------------------------------------------
/src/lib/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './layout'
2 |
--------------------------------------------------------------------------------
/src/lib/components/layout.tsx:
--------------------------------------------------------------------------------
1 | import {WstackProps} from '@app/types/widget'
2 | import {FC} from 'react'
3 |
4 | interface StackProps extends WstackProps {
5 | justifyContent?: 'flex-start' | 'center' | 'flex-end'
6 | alignItems?: 'flex-start' | 'center' | 'flex-end'
7 | }
8 | export const Stack: FC = ({children, ...props}) => {
9 | const {justifyContent = 'flex-start', alignItems = 'flex-start', ...wstackProps} = props
10 | const {flexDirection = 'row'} = wstackProps
11 |
12 | const JustifyContentFlexStart: FC = ({children, ...props}) => {
13 | const {flexDirection = 'row'} = props
14 | return (
15 |
16 | {children}
17 |
18 |
19 | )
20 | }
21 | const JustifyContentCenter: FC = ({children, ...props}) => {
22 | const {flexDirection = 'row'} = props
23 | return (
24 |
25 |
26 | {children}
27 |
28 |
29 | )
30 | }
31 | const JustifyContentFlexEnd: FC = ({children, ...props}) => {
32 | const {flexDirection = 'row'} = props
33 | return (
34 |
35 |
36 | {children}
37 |
38 | )
39 | }
40 | const AlignItemsFlexStart: FC = ({children, ...props}) => {
41 | const {flexDirection = 'row', ...wstackProps} = props
42 | return (
43 |
44 | {children}
45 |
46 |
47 | )
48 | }
49 | const AlignItemsCenter: FC = ({children, ...props}) => {
50 | const {flexDirection = 'row', ...wstackProps} = props
51 | return (
52 |
53 |
54 | {children}
55 |
56 |
57 | )
58 | }
59 | const AlignItemsFlexEnd: FC = ({children, ...props}) => {
60 | const {flexDirection = 'row', ...wstackProps} = props
61 | return (
62 |
63 |
64 | {children}
65 |
66 | )
67 | }
68 | const JustifyContentMap: Record, FC> = {
69 | 'flex-start': JustifyContentFlexStart,
70 | center: JustifyContentCenter,
71 | 'flex-end': JustifyContentFlexEnd,
72 | }
73 | const AlignItemsMap: Record, FC> = {
74 | 'flex-start': AlignItemsFlexStart,
75 | center: AlignItemsCenter,
76 | 'flex-end': AlignItemsFlexEnd,
77 | }
78 |
79 | const _children = Array.isArray(children)
80 | ? children.map(child => {
81 | return h(AlignItemsMap[alignItems], {flexDirection}, child)
82 | })
83 | : h(AlignItemsMap[alignItems], {flexDirection}, children)
84 |
85 | return h(JustifyContentMap[justifyContent], {...wstackProps}, _children)
86 | }
87 |
88 | export const Row: FC = ({children, ...props}) => {
89 | props.flexDirection = 'row'
90 | return {children}
91 | }
92 |
93 | export const Col: FC = ({children, ...props}) => {
94 | props.flexDirection = 'column'
95 | return {children}
96 | }
97 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | /**url启动脚本的来源*/
2 | export enum URLSchemeFrom {
3 | /**从 widget 点击事件进入会携带上这个参数*/
4 | WIDGET = 'widget',
5 | }
6 |
7 | /**服务端口*/
8 | export const port = 9090
9 |
--------------------------------------------------------------------------------
/src/lib/env.ts:
--------------------------------------------------------------------------------
1 | import * as dotenv from 'dotenv'
2 | import path from 'path'
3 | import dotenvExpand from 'dotenv-expand'
4 |
5 | /**
6 | * 加载环境变量 dotenv 文件
7 | * @param projectRootPath 项目根目录
8 | */
9 | export function loadEnvFiles(projectRootPath: string): void {
10 | /**
11 | * 加载 dotenv 文件
12 | * .env # 在所有的环境中被载入
13 | * .env.local # 在所有的环境中被载入,但会被 git 忽略
14 | * .env.[mode] # 只在指定的模式中被载入
15 | * .env.[mode].local # 只在指定的模式中被载入,但会被 git 忽略
16 | * @param mode 环境
17 | */
18 | const loadEnv = (mode?: string): void => {
19 | const basePath = path.resolve(projectRootPath, `.env${mode ? `.${mode}` : ``}`)
20 | const localPath = `${basePath}.local`
21 |
22 | const load = (envPath: string) => {
23 | try {
24 | const env = dotenv.config({path: envPath})
25 | dotenvExpand(env)
26 | } catch (err) {}
27 | }
28 | // console.log(localPath, basePath)
29 | load(localPath)
30 | load(basePath)
31 | }
32 |
33 | const mode = process.env.NODE_ENV
34 |
35 | // 这里的环境变量不会被下面覆盖,所以优先级最高
36 | // .env.production、.env.production.local、.env.development、.env.development.local
37 | if (mode) {
38 | loadEnv(mode)
39 | }
40 |
41 | //.env、.env.local
42 | loadEnv()
43 | }
44 |
--------------------------------------------------------------------------------
/src/lib/jsx-runtime.ts:
--------------------------------------------------------------------------------
1 | import {WboxProps, WimageProps, WdateProps, WspacerProps, WstackProps, WtextProps} from '../types/widget'
2 | import {getImage, hash} from './help'
3 | import {URLSchemeFrom} from './constants'
4 |
5 | type WidgetType = 'wbox' | 'wdate' | 'wimage' | 'wspacer' | 'wstack' | 'wtext'
6 | type WidgetProps = WboxProps | WdateProps | WspacerProps | WstackProps | WtextProps | WimageProps
7 | type Children = ((instance: T) => Promise)[]
8 | /**属性对应关系*/
9 | type KeyMap = Record, () => void>
10 |
11 | class GenrateView {
12 | public static listWidget: ListWidget
13 | static setListWidget(listWidget: ListWidget): void {
14 | this.listWidget = listWidget
15 | }
16 | // 根组件
17 | static async wbox(props: WboxProps, ...children: Children) {
18 | const {background, spacing, href, updateDate, padding, onClick} = props
19 | try {
20 | // background
21 | isDefined(background) && (await setBackground(this.listWidget, background))
22 | // spacing
23 | isDefined(spacing) && (this.listWidget.spacing = spacing)
24 | // href
25 | isDefined(href) && (this.listWidget.url = href)
26 | // updateDate
27 | isDefined(updateDate) && (this.listWidget.refreshAfterDate = updateDate)
28 | // padding
29 | isDefined(padding) && this.listWidget.setPadding(...padding)
30 | // onClick
31 | isDefined(onClick) && runOnClick(this.listWidget, onClick)
32 | await addChildren(this.listWidget, children)
33 | } catch (err) {
34 | console.error(err)
35 | }
36 | return this.listWidget
37 | }
38 | // 容器组件
39 | static wstack(props: WstackProps, ...children: Children) {
40 | return async (
41 | parentInstance: Scriptable.Widget & {
42 | addStack(): WidgetStack
43 | },
44 | ) => {
45 | const widgetStack = parentInstance.addStack()
46 | const {
47 | background,
48 | spacing,
49 | padding,
50 | width = 0,
51 | height = 0,
52 | borderRadius,
53 | borderWidth,
54 | borderColor,
55 | href,
56 | verticalAlign,
57 | flexDirection,
58 | onClick,
59 | } = props
60 | try {
61 | // background
62 | isDefined(background) && (await setBackground(widgetStack, background))
63 | // spacing
64 | isDefined(spacing) && (widgetStack.spacing = spacing)
65 | // padding
66 | isDefined(padding) && widgetStack.setPadding(...padding)
67 | // borderRadius
68 | isDefined(borderRadius) && (widgetStack.cornerRadius = borderRadius)
69 | // borderWidth
70 | isDefined(borderWidth) && (widgetStack.borderWidth = borderWidth)
71 | // borderColor
72 | isDefined(borderColor) && (widgetStack.borderColor = getColor(borderColor))
73 | // href
74 | isDefined(href) && (widgetStack.url = href)
75 | // width、height
76 | widgetStack.size = new Size(width, height)
77 | // verticalAlign
78 | const verticalAlignMap: KeyMap = {
79 | bottom: () => widgetStack.bottomAlignContent(),
80 | center: () => widgetStack.centerAlignContent(),
81 | top: () => widgetStack.topAlignContent(),
82 | }
83 | isDefined(verticalAlign) && verticalAlignMap[verticalAlign]()
84 | // flexDirection
85 | const flexDirectionMap: KeyMap = {
86 | row: () => widgetStack.layoutHorizontally(),
87 | column: () => widgetStack.layoutVertically(),
88 | }
89 | isDefined(flexDirection) && flexDirectionMap[flexDirection]()
90 | // onClick
91 | isDefined(onClick) && runOnClick(widgetStack, onClick)
92 | } catch (err) {
93 | console.error(err)
94 | }
95 | await addChildren(widgetStack, children)
96 | }
97 | }
98 | // 图片组件
99 | static wimage(props: WimageProps) {
100 | return async (
101 | parentInstance: Scriptable.Widget & {
102 | addImage(image: Image): WidgetImage
103 | },
104 | ) => {
105 | const {
106 | src,
107 | href,
108 | resizable,
109 | width = 0,
110 | height = 0,
111 | opacity,
112 | borderRadius,
113 | borderWidth,
114 | borderColor,
115 | containerRelativeShape,
116 | filter,
117 | imageAlign,
118 | mode,
119 | onClick,
120 | } = props
121 |
122 | let _image: Image = src as Image
123 |
124 | // src 为网络连接时
125 | typeof src === 'string' && isUrl(src) && (_image = await getImage({url: src}))
126 |
127 | // src 为 icon name 时
128 | typeof src === 'string' && !isUrl(src) && (_image = SFSymbol.named(src).image)
129 | const widgetImage = parentInstance.addImage(_image)
130 | widgetImage.image = _image
131 |
132 | try {
133 | // href
134 | isDefined(href) && (widgetImage.url = href)
135 | // resizable
136 | isDefined(resizable) && (widgetImage.resizable = resizable)
137 | // width、height
138 | widgetImage.imageSize = new Size(width, height)
139 | // opacity
140 | isDefined(opacity) && (widgetImage.imageOpacity = opacity)
141 | // borderRadius
142 | isDefined(borderRadius) && (widgetImage.cornerRadius = borderRadius)
143 | // borderWidth
144 | isDefined(borderWidth) && (widgetImage.borderWidth = borderWidth)
145 | // borderColor
146 | isDefined(borderColor) && (widgetImage.borderColor = getColor(borderColor))
147 | // containerRelativeShape
148 | isDefined(containerRelativeShape) && (widgetImage.containerRelativeShape = containerRelativeShape)
149 | // filter
150 | isDefined(filter) && (widgetImage.tintColor = getColor(filter))
151 | // imageAlign
152 | const imageAlignMap: KeyMap = {
153 | left: () => widgetImage.leftAlignImage(),
154 | center: () => widgetImage.centerAlignImage(),
155 | right: () => widgetImage.rightAlignImage(),
156 | }
157 | isDefined(imageAlign) && imageAlignMap[imageAlign]()
158 | // mode
159 | const modeMap: KeyMap = {
160 | fit: () => widgetImage.applyFittingContentMode(),
161 | fill: () => widgetImage.applyFillingContentMode(),
162 | }
163 | isDefined(mode) && modeMap[mode]()
164 | // onClick
165 | isDefined(onClick) && runOnClick(widgetImage, onClick)
166 | } catch (err) {
167 | console.error(err)
168 | }
169 | }
170 | }
171 | // 占位空格组件
172 | static wspacer(props: WspacerProps) {
173 | return async (
174 | parentInstance: Scriptable.Widget & {
175 | addSpacer(length?: number): WidgetSpacer
176 | },
177 | ) => {
178 | const widgetSpacer = parentInstance.addSpacer()
179 | const {length} = props
180 | try {
181 | // length
182 | isDefined(length) && (widgetSpacer.length = length)
183 | } catch (err) {
184 | console.error(err)
185 | }
186 | }
187 | }
188 | // 文字组件
189 | static wtext(props: WtextProps, ...children: string[]) {
190 | return async (
191 | parentInstance: Scriptable.Widget & {
192 | addText(text?: string): WidgetText
193 | },
194 | ) => {
195 | const widgetText = parentInstance.addText('')
196 | const {
197 | textColor,
198 | font,
199 | opacity,
200 | maxLine,
201 | scale,
202 | shadowColor,
203 | shadowRadius,
204 | shadowOffset,
205 | href,
206 | textAlign,
207 | onClick,
208 | } = props
209 |
210 | if (children && Array.isArray(children)) {
211 | widgetText.text = children.join('')
212 | }
213 | try {
214 | // textColor
215 | isDefined(textColor) && (widgetText.textColor = getColor(textColor))
216 | // font
217 | isDefined(font) && (widgetText.font = typeof font === 'number' ? Font.systemFont(font) : font)
218 | // opacity
219 | isDefined(opacity) && (widgetText.textOpacity = opacity)
220 | // maxLine
221 | isDefined(maxLine) && (widgetText.lineLimit = maxLine)
222 | // scale
223 | isDefined(scale) && (widgetText.minimumScaleFactor = scale)
224 | // shadowColor
225 | isDefined(shadowColor) && (widgetText.shadowColor = getColor(shadowColor))
226 | // shadowRadius
227 | isDefined(shadowRadius) && (widgetText.shadowRadius = shadowRadius)
228 | // shadowOffset
229 | isDefined(shadowOffset) && (widgetText.shadowOffset = shadowOffset)
230 | // href
231 | isDefined(href) && (widgetText.url = href)
232 | //textAlign
233 | const textAlignMap: KeyMap = {
234 | left: () => widgetText.leftAlignText(),
235 | center: () => widgetText.centerAlignText(),
236 | right: () => widgetText.rightAlignText(),
237 | }
238 | isDefined(textAlign) && textAlignMap[textAlign]()
239 | // onClick
240 | isDefined(onClick) && runOnClick(widgetText, onClick)
241 | } catch (err) {
242 | console.error(err)
243 | }
244 | }
245 | }
246 | // 日期组件
247 | static wdate(props: WdateProps) {
248 | return async (
249 | parentInstance: Scriptable.Widget & {
250 | addDate(date: Date): WidgetDate
251 | },
252 | ) => {
253 | const widgetDate = parentInstance.addDate(new Date())
254 | const {
255 | date,
256 | mode,
257 | textColor,
258 | font,
259 | opacity,
260 | maxLine,
261 | scale,
262 | shadowColor,
263 | shadowRadius,
264 | shadowOffset,
265 | href,
266 | textAlign,
267 | onClick,
268 | } = props
269 |
270 | try {
271 | // date
272 | isDefined(date) && (widgetDate.date = date)
273 | // textColor
274 | isDefined(textColor) && (widgetDate.textColor = getColor(textColor))
275 | // font
276 | isDefined(font) && (widgetDate.font = typeof font === 'number' ? Font.systemFont(font) : font)
277 | // opacity
278 | isDefined(opacity) && (widgetDate.textOpacity = opacity)
279 | // maxLine
280 | isDefined(maxLine) && (widgetDate.lineLimit = maxLine)
281 | // scale
282 | isDefined(scale) && (widgetDate.minimumScaleFactor = scale)
283 | // shadowColor
284 | isDefined(shadowColor) && (widgetDate.shadowColor = getColor(shadowColor))
285 | // shadowRadius
286 | isDefined(shadowRadius) && (widgetDate.shadowRadius = shadowRadius)
287 | // shadowOffset
288 | isDefined(shadowOffset) && (widgetDate.shadowOffset = shadowOffset)
289 | // href
290 | isDefined(href) && (widgetDate.url = href)
291 | // mode
292 | const modeMap: KeyMap = {
293 | time: () => widgetDate.applyTimeStyle(),
294 | date: () => widgetDate.applyDateStyle(),
295 | relative: () => widgetDate.applyRelativeStyle(),
296 | offset: () => widgetDate.applyOffsetStyle(),
297 | timer: () => widgetDate.applyTimerStyle(),
298 | }
299 | isDefined(mode) && modeMap[mode]()
300 | // textAlign
301 | const textAlignMap: KeyMap = {
302 | left: () => widgetDate.leftAlignText(),
303 | center: () => widgetDate.centerAlignText(),
304 | right: () => widgetDate.rightAlignText(),
305 | }
306 | isDefined(textAlign) && textAlignMap[textAlign]()
307 | // onClick
308 | isDefined(onClick) && runOnClick(widgetDate, onClick)
309 | } catch (err) {
310 | console.error(err)
311 | }
312 | }
313 | }
314 | }
315 |
316 | const listWidget = new ListWidget()
317 | GenrateView.setListWidget(listWidget)
318 | export function h(
319 | type: WidgetType | (() => () => void),
320 | props?: WidgetProps,
321 | ...children: Children | string[]
322 | ): Promise | unknown {
323 | props = props || {}
324 |
325 | // 由于 Fragment 的存在,children 可能为多维数组混合,先把它展平
326 | const _children = flatteningArr(children as unknown[]) as typeof children
327 |
328 | switch (type) {
329 | case 'wbox':
330 | return GenrateView.wbox(props as WboxProps, ...(_children as Children))
331 | break
332 | case 'wdate':
333 | return GenrateView.wdate(props as WdateProps)
334 | break
335 | case 'wimage':
336 | return GenrateView.wimage(props as WimageProps)
337 | break
338 | case 'wspacer':
339 | return GenrateView.wspacer(props as WspacerProps)
340 | break
341 | case 'wstack':
342 | return GenrateView.wstack(props as WstackProps, ...(_children as Children))
343 | break
344 | case 'wtext':
345 | return GenrateView.wtext(props as WtextProps, ...(_children as string[]))
346 | break
347 | default:
348 | // custom component
349 | return type instanceof Function
350 | ? ((type as unknown) as (props: WidgetProps) => Promise)({children: _children, ...props})
351 | : null
352 | break
353 | }
354 | }
355 |
356 | export function Fragment({children}: {children: typeof h[]}): typeof h[] {
357 | return children
358 | }
359 |
360 | /**
361 | * 展平所有维度数组
362 | * @param arr 数组
363 | */
364 | function flatteningArr(arr: T[]): T[] {
365 | return [].concat(
366 | ...arr.map((item: T | T[]) => {
367 | return (Array.isArray(item) ? flatteningArr(item) : item) as never[]
368 | }),
369 | ) as T[]
370 | }
371 |
372 | /**
373 | * 输出真正颜色(比如string转color)
374 | * @param color
375 | */
376 | function getColor(color: Color | string): Color {
377 | return typeof color === 'string' ? new Color(color, 1) : color
378 | }
379 |
380 | /**
381 | * 输出真正背景(比如string转color)
382 | * @param bg 输入背景参数
383 | */
384 | async function getBackground(bg: Color | Image | LinearGradient | string): Promise {
385 | bg = (typeof bg === 'string' && !isUrl(bg)) || bg instanceof Color ? getColor(bg) : bg
386 | if (typeof bg === 'string') {
387 | bg = await getImage({url: bg})
388 | }
389 | return bg
390 | }
391 |
392 | /**
393 | * 设置背景
394 | * @param widget 实例
395 | * @param bg 背景
396 | */
397 | async function setBackground(
398 | widget: Scriptable.Widget & {
399 | backgroundColor: Color
400 | backgroundImage: Image
401 | backgroundGradient: LinearGradient
402 | },
403 | bg: Color | Image | LinearGradient | string,
404 | ): Promise {
405 | const _bg = await getBackground(bg)
406 | if (_bg instanceof Color) {
407 | widget.backgroundColor = _bg
408 | }
409 | if (_bg instanceof Image) {
410 | widget.backgroundImage = _bg
411 | }
412 | if (_bg instanceof LinearGradient) {
413 | widget.backgroundGradient = _bg
414 | }
415 | }
416 |
417 | /**
418 | * 添加子组件列表(把当前实例传下去)
419 | * @param instance 当前实例
420 | * @param children 子组件列表
421 | */
422 | async function addChildren(instance: T, children: Children): Promise {
423 | if (children && Array.isArray(children)) {
424 | for (const child of children) {
425 | child instanceof Function ? await child(instance) : ''
426 | }
427 | }
428 | }
429 |
430 | /**
431 | * 如果某值不是 undefined、null、NaN 则返回 true
432 | * @param value 值
433 | */
434 | function isDefined(value: T | undefined | null): value is T {
435 | if (typeof value === 'number' && !isNaN(value)) {
436 | return true
437 | }
438 | return value !== undefined && value !== null
439 | }
440 |
441 | /**
442 | * 判断一个值是否为网络连接
443 | * @param value 值
444 | */
445 | function isUrl(value: string): boolean {
446 | const reg = /^(http|https)\:\/\/[\w\W]+/
447 | return reg.test(value)
448 | }
449 |
450 | /**
451 | * 执行点击事件
452 | * @param instance 当前实例
453 | * @param onClick 点击事件
454 | */
455 | function runOnClick(instance: T, onClick: () => unknown) {
456 | const _eventId = hash(onClick.toString())
457 | instance.url = `${URLScheme.forRunningScript()}?eventId=${encodeURIComponent(_eventId)}&from=${URLSchemeFrom.WIDGET}`
458 | const {eventId, from} = args.queryParameters
459 | if (eventId && eventId === _eventId && from === URLSchemeFrom.WIDGET) {
460 | onClick()
461 | }
462 | }
463 |
--------------------------------------------------------------------------------
/src/lib/server.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import ip from 'ip'
3 | import bodyParser from 'body-parser'
4 | import {port} from './constants'
5 | import chalk from 'chalk'
6 | import fs from 'fs'
7 | import path from 'path'
8 | const qrcode = require('qrcode-terminal')
9 |
10 | interface CreateServerParams {
11 | /**要映射的静态文件夹*/
12 | staticDir: string
13 |
14 | /**是否显示二维码*/
15 | showQrcode?: boolean
16 | }
17 |
18 | interface ConsoleApiBody {
19 | type: 'log' | 'warn' | 'error'
20 | data: unknown
21 | }
22 |
23 | enum ResCode {
24 | SUCCESS = 0,
25 | FAIL = 1,
26 | }
27 |
28 | interface Res {
29 | code: ResCode
30 | msg: string
31 | data?: T
32 | }
33 |
34 | /**
35 | * 创建服务器
36 | * @param staticDir 服务器映射静态文件夹
37 | */
38 | export function createServer(params: CreateServerParams): {serverApi: string} {
39 | const {staticDir, showQrcode = true} = params
40 | const app = express()
41 |
42 | /** server api 地址*/
43 | const serverApi = `http://${ip.address('public')}:${port}`
44 |
45 | // 解析请求参数
46 | app.use(
47 | bodyParser.urlencoded({
48 | extended: false,
49 | }),
50 | )
51 |
52 | app.use(bodyParser.json())
53 |
54 | // 映射静态文件夹
55 | app.use(express.static(staticDir))
56 |
57 | app.get('/', (req, res) => {
58 | let html = fs.readFileSync(path.resolve(__dirname, './static', './dev-help.html'), {encoding: 'utf8'}).toString()
59 |
60 | /**自动安装基础包并命名的代码*/
61 | const installBasicCode = `
62 | (async() => {
63 | const fm = FileManager[module.filename.includes('Documents/iCloud~') ? 'iCloud' : 'local']()
64 | const notify = new Notification()
65 | notify.sound = 'default'
66 | try {
67 | const req = new Request('${serverApi}/basic.js')
68 | const code = await req.loadString()
69 | const newFilename = module.filename.split('/').slice(0, -1).concat(['基础包.js']).join('/')
70 | fm.writeString(
71 | newFilename,
72 | \`// Variables used by Scriptable.
73 | // These must be at the very top of the file. Do not edit.
74 | // icon-color: deep-gray; icon-glyph: mobile-alt;
75 | \${code}\`)
76 |
77 | // 通知
78 | notify.title = '安装基础包成功'
79 | notify.schedule()
80 |
81 | fm.remove(module.filename)
82 | Safari.open("scriptable:///open?scriptName="+encodeURIComponent('基础包'));
83 | } catch(err) {
84 | console.error(err)
85 | notify.title = '安装基础包失败'
86 | notify.body = err.message || ''
87 | notify.schedule()
88 | }
89 | })()
90 | `
91 | html = html.replace('@@code@@', installBasicCode)
92 | res.send(html)
93 | })
94 |
95 | app.get('/basic.js', (req, res) => {
96 | const js = fs.readFileSync(path.resolve(__dirname, './static', './基础包.js'), {encoding: 'utf8'}).toString()
97 |
98 | res.send(js)
99 | })
100 |
101 | app.post('/console', (req, res) => {
102 | const {type = 'log', data = ''} = req.body as ConsoleApiBody
103 | const logTime = new Date().toLocaleString().split(' ')[1]
104 | const logParams = [`[${type} ${logTime}]`, typeof data !== 'object' ? data : JSON.stringify(data, null, 2)]
105 | switch (type) {
106 | case 'log':
107 | console.log(chalk.green(...logParams))
108 | break
109 | case 'warn':
110 | console.warn(chalk.yellow(...logParams))
111 | break
112 | case 'error':
113 | console.error(chalk.red(...logParams))
114 | break
115 | default:
116 | console.log(chalk.green(...logParams))
117 | break
118 | }
119 |
120 | res.send({
121 | code: ResCode.SUCCESS,
122 | msg: 'success',
123 | } as Res)
124 | })
125 |
126 | app.listen(port)
127 |
128 | // console.clear()
129 | console.log(`手机访问 ${serverApi}`)
130 | showQrcode && qrcode.generate(serverApi, {small: true})
131 | return {serverApi}
132 | }
133 |
--------------------------------------------------------------------------------
/src/lib/static/dev-help.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | Scriptable开发引导
10 |
64 |
65 |
66 |
67 |
1、点击复制下方的代码
68 |
2、点击打开 Scriptable 粘贴运行
69 |
70 |
71 |
72 |
130 |
131 |
132 |
--------------------------------------------------------------------------------
/src/scripts/bili.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * 哔哩粉丝
3 | * 改写于 https://github.com/im3x/Scriptables/blob/main/bilibili/fans.js
4 | */
5 | import {
6 | getImage,
7 | request,
8 | showActionSheet,
9 | useStorage,
10 | showPreviewOptions,
11 | showModal,
12 | ResponseType,
13 | isLaunchInsideApp,
14 | } from '@app/lib/help'
15 |
16 | export interface BiliUpData {
17 | code: number
18 | message: string
19 | ttl: number
20 | data: {
21 | mid: number
22 | following: number
23 | whisper: number
24 | black: number
25 | follower: number
26 | }
27 | }
28 |
29 | const {setStorage, getStorage} = useStorage('bilibili-fans')
30 |
31 | class BiliFans {
32 | async init() {
33 | if (isLaunchInsideApp()) {
34 | return await this.showMenu()
35 | }
36 | const widget = (await this.render()) as ListWidget
37 | Script.setWidget(widget)
38 | Script.complete()
39 | }
40 |
41 | //渲染组件
42 | async render(): Promise {
43 | // up 主 id
44 | const upId = getStorage('up-id') || 0
45 |
46 | // 粉丝数
47 | let follower = -1
48 | try {
49 | // 响应数据
50 | const getUpDataRes = (await this.getUpData(upId)).data as BiliUpData
51 | follower = getUpDataRes.data.follower as number
52 | } catch (err) {
53 | console.warn('获取粉丝数失败')
54 | }
55 |
56 | // icon
57 | const icon = await getImage({url: 'https://www.bilibili.com/favicon.ico'})
58 |
59 | // 粉丝数文字
60 | const FollowerText = () => {
61 | if (follower < 0) {
62 | return (
63 |
64 | 请填写B站UP主的ID
65 |
66 | )
67 | } else {
68 | return (
69 |
70 | {this.toThousands(follower)}
71 |
72 | )
73 | }
74 | }
75 |
76 | // 渲染
77 | return (
78 |
79 |
80 |
81 |
82 |
83 | 哔哩哔哩粉丝
84 |
85 |
86 |
87 |
88 |
89 |
90 | 更新于:{this.nowTime()}
91 |
92 |
93 | )
94 | }
95 |
96 | // 显示菜单
97 | async showMenu(): Promise {
98 | const selectIndex = await showActionSheet({
99 | title: '菜单',
100 | itemList: ['设置 up 主 id', '预览尺寸'],
101 | })
102 | switch (selectIndex) {
103 | case 0:
104 | // 设置 up 主 id
105 | const {cancel, texts} = await showModal({
106 | title: '请输入 up 主的 id',
107 | inputItems: [
108 | {
109 | text: getStorage('up-id') || '',
110 | placeholder: '去网页版 up 主页,可以看到 id',
111 | },
112 | ],
113 | })
114 | if (cancel) return
115 | // 保存 id
116 | if (texts && texts[0]) setStorage('up-id', texts[0])
117 | break
118 | case 1:
119 | // 预览尺寸
120 | await showPreviewOptions(this.render.bind(this))
121 | break
122 | }
123 | }
124 |
125 | // 获取b站up数据
126 | async getUpData(id: number): Promise> {
127 | return await request({
128 | url: `http://api.bilibili.com/x/relation/stat?vmid=${id}`,
129 | dataType: 'json',
130 | })
131 | }
132 |
133 | //格式化粉丝数量,加入千分号
134 | toThousands(num: number): string {
135 | return (num || 0).toString().replace(/(\d)(?=(?:\d{3})+$)/g, '$1,')
136 | }
137 |
138 | //根据粉丝数量返回不同的字体大小
139 | getFontsize(num: number) {
140 | if (num < 99) {
141 | return 38
142 | } else if (num < 9999 && num > 100) {
143 | return 30
144 | } else if (num < 99999 && num > 10000) {
145 | return 28
146 | } else if (num < 999999 && num > 100000) {
147 | return 24
148 | } else if (num < 9999999 && num > 1000000) {
149 | return 22
150 | } else {
151 | return 20
152 | }
153 | }
154 |
155 | //当前时间
156 | nowTime(): string {
157 | const date = new Date()
158 | return date.toLocaleTimeString('chinese', {hour12: false})
159 | }
160 | }
161 |
162 | EndAwait(() => new BiliFans().init())
163 |
--------------------------------------------------------------------------------
/src/scripts/china10010.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * 联通话费流量查询小部件
3 | */
4 |
5 | import {
6 | isLaunchInsideApp,
7 | setTransparentBackground,
8 | showActionSheet,
9 | showModal,
10 | showNotification,
11 | showPreviewOptions,
12 | useStorage,
13 | request,
14 | sleep,
15 | } from '@app/lib/help'
16 | import {FC} from 'react'
17 | import {WtextProps, WstackProps} from '@app/types/widget'
18 |
19 | /**手机卡数据列表*/
20 | interface PhoneDatas {
21 | data: {
22 | dataList: PhoneData[]
23 | }
24 | }
25 |
26 | /**手机卡数据*/
27 | interface PhoneData {
28 | pointUpdateTimeStamp: string
29 | paperwork4: string
30 | buttonBacImageUrlBig: string
31 | type: PhoneDataType
32 | remainTitle: string
33 | buttonText7: string
34 | buttonLinkMode: string
35 | displayTime: string
36 | buttonText7TextColor: string
37 | buttonBacImageUrlSmall: string
38 | button: string
39 | remainTitleColoer: string
40 | buttonBacImageUrl: string
41 | buttonTextColor: string
42 | isShake: string
43 | number: string
44 | isWarn?: string
45 | paperwork4Coloer: string
46 | url: string
47 | buttonUrl7: string
48 | unit: string
49 | button7LinkMode: string
50 | persent: string
51 | persentColor: string
52 | buttonAddress: string
53 | usedTitle: string
54 | ballRippleColor1: string
55 | ballRippleColor2: string
56 | markerImg?: string
57 | warningTextColor?: string
58 | warningPointColor?: string
59 | }
60 |
61 | /**有用的手机卡数据*/
62 | interface UsefulPhoneData {
63 | /**类型*/
64 | type: PhoneDataType
65 |
66 | /**剩余百分比数字*/
67 | percent: number
68 |
69 | /**单位*/
70 | unit: string
71 |
72 | /**剩余量数字*/
73 | count: number
74 |
75 | /**描述*/
76 | label: string
77 | }
78 |
79 | /**手机卡数据类型*/
80 | enum PhoneDataType {
81 | /**流量*/
82 | FLOW = 'flow',
83 |
84 | /**话费*/
85 | FEE = 'fee',
86 |
87 | /**语音*/
88 | VOICE = 'voice',
89 |
90 | /**积分*/
91 | POINT = 'point',
92 |
93 | /**信用分*/
94 | CREDIT = 'credit',
95 |
96 | /**电子券*/
97 | WOPAY = 'woPay',
98 | }
99 |
100 | const typeDesc: Record = {
101 | [PhoneDataType.FLOW]: '流量',
102 | [PhoneDataType.FEE]: '话费',
103 | [PhoneDataType.VOICE]: '语音',
104 | [PhoneDataType.POINT]: '积分',
105 | [PhoneDataType.CREDIT]: '信用分',
106 | [PhoneDataType.WOPAY]: '电子券',
107 | }
108 |
109 | // 格式化数字
110 | const formatNum = (num: number) => parseFloat(Number(num).toFixed(1))
111 |
112 | const {setStorage, getStorage} = useStorage('china10010-xiaoming')
113 |
114 | /**默认背景颜色*/
115 | const defaultBgColor = Color.dynamic(new Color('#ffffff', 1), new Color('#000000', 1))
116 |
117 | /**文字颜色*/
118 | const textColor = getStorage('textColor') || Color.dynamic(new Color('#000000', 1), new Color('#dddddd', 1))
119 |
120 | /**透明背景*/
121 | const transparentBg: Image | Color = getStorage('transparentBg') || defaultBgColor
122 |
123 | /**背景颜色或背景图链接*/
124 | const boxBg = getStorage('boxBg') || defaultBgColor
125 |
126 | class China10010 {
127 | async init() {
128 | if (isLaunchInsideApp()) {
129 | return await this.showMenu()
130 | }
131 | const widget = (await this.render()) as ListWidget
132 | Script.setWidget(widget)
133 | Script.complete()
134 | }
135 |
136 | //渲染组件
137 | async render(): Promise {
138 | if (isLaunchInsideApp()) {
139 | await showNotification({title: '稍等片刻', body: '小部件渲染中...', sound: 'alert'})
140 | }
141 | // 多久(毫秒)更新一次小部件(默认3分钟)
142 | const updateInterval = 1 * 60 * 1000
143 |
144 | // 渲染尺寸
145 | const size = config.widgetFamily
146 |
147 | // 获取数据
148 | const usefulPhoneDatas = await this.getUserfulPhoneData(getStorage('phoneNumber') || '')
149 |
150 | if (typeof usefulPhoneDatas === 'string') {
151 | return (
152 |
153 |
154 | {usefulPhoneDatas}
155 |
156 |
157 | )
158 | }
159 | console.log(usefulPhoneDatas)
160 | return (
161 |
166 |
167 |
168 | {/* 标题和logo */}
169 |
170 |
176 |
177 |
178 | 中国联通
179 |
180 |
181 |
182 | {/* 内容 */}
183 | {size === 'small' && this.renderSmall(usefulPhoneDatas)}
184 | {size === 'medium' && this.renderMedium(usefulPhoneDatas)}
185 | {size === 'large' && this.renderLarge(usefulPhoneDatas)}
186 |
187 |
188 | )
189 | }
190 |
191 | // 渲染小尺寸
192 | renderSmall(usefulPhoneDatas: UsefulPhoneData[]) {
193 | // 流量
194 | const flow = usefulPhoneDatas.find(item => item.type === PhoneDataType.FLOW) as UsefulPhoneData
195 | // 话费
196 | const fee = usefulPhoneDatas.find(item => item.type === PhoneDataType.FEE) as UsefulPhoneData
197 | return (
198 | <>
199 |
200 | 剩余流量{formatNum(flow.count || 0) + flow.unit}
201 |
202 |
203 |
204 | 剩余话费{formatNum(fee.count || 0) + fee.unit}
205 |
206 |
207 | >
208 | )
209 | }
210 |
211 | // 渲染中尺寸
212 | renderMedium(usefulPhoneDatas: UsefulPhoneData[]) {
213 | const showDataType: PhoneDataType[] = [PhoneDataType.FLOW, PhoneDataType.FEE, PhoneDataType.VOICE]
214 | return this.renderLarge(usefulPhoneDatas.filter(data => showDataType.indexOf(data.type) >= 0))
215 | }
216 |
217 | // 渲染大尺寸
218 | renderLarge(usefulPhoneDatas: UsefulPhoneData[]) {
219 | /**进度条*/
220 | const Progress: FC<{
221 | color: WstackProps['background']
222 | bgcolor: WstackProps['background']
223 | progress: number
224 | width: number
225 | height: number
226 | borderRadius?: number
227 | }> = ({...props}) => {
228 | const {color, bgcolor, progress, width, height, borderRadius = 0} = props
229 | return (
230 |
231 |
232 |
233 |
234 | {progress < 1 && }
235 |
236 | )
237 | }
238 |
239 | /**表格格子*/
240 | const TableGrid: FC<
241 | WtextProps & {text: string | React.ReactNode; width: number; align: 'left' | 'center' | 'right'}
242 | > = ({text, width, align, ...props}) => (
243 |
244 | {(align === 'center' || align === 'right') && }
245 | {typeof text === 'string' ? (
246 |
247 | {text}
248 |
249 | ) : (
250 | text
251 | )}
252 | {(align === 'center' || align === 'left') && }
253 |
254 | )
255 |
256 | /**表格行*/
257 | const TableRow: FC = ({texts, ...props}) => (
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 | )
266 | return (
267 | <>
268 |
269 | {usefulPhoneDatas.map(item => (
270 | <>
271 |
272 |
287 | >
288 | ))}
289 |
290 | >
291 | )
292 | }
293 |
294 | // 显示菜单
295 | async showMenu() {
296 | const selectIndex = await showActionSheet({
297 | title: '菜单',
298 | itemList: ['登录获取cookie', '设置手机号和cookie', '设置颜色', '设置透明背景', '预览组件'],
299 | })
300 | switch (selectIndex) {
301 | case 0:
302 | const {cancel: cancelLogin} = await showModal({
303 | title: '为什么要登录',
304 | content:
305 | '获取手机号码信息需要 cookie,而 cookie 不登录获取不到\n\n登录完成后,关闭网页,网页会再自动打开\n\n此时点击底部按钮复制 cookie ,然后关网页去设置cookie\n\n若 cookie 失效,再次登录复制即可',
306 | confirmText: '去登录',
307 | })
308 | if (cancelLogin) return
309 | const loginUrl = 'http://wap.10010.com/mobileService/myunicom.htm'
310 | const webview = new WebView()
311 | await webview.loadURL(loginUrl)
312 | await webview.present()
313 |
314 | /**循环插入脚本等待时间,单位:毫秒*/
315 | const sleepTime = 1000
316 |
317 | /**循环时间统计,单位:毫秒*/
318 | let waitTimeCount = 0
319 |
320 | /**最大循环时间,单位:毫秒*/
321 | const maxWaitTime = 10 * 60 * sleepTime
322 |
323 | while (true) {
324 | if (waitTimeCount >= maxWaitTime) break
325 | const {isAddCookieBtn} = (await webview.evaluateJavaScript(`
326 | window.isAddCookieBtn = false
327 | if (document.cookie.match('jsessionid')) {
328 | const copyWrap = document.createElement('div')
329 | copyWrap.innerHTML = \`
330 | 复制cookie
331 | \`
332 | function copy(text) {
333 | var input = document.createElement('input');
334 | input.setAttribute('value', text);
335 | document.body.appendChild(input);
336 | input.select();
337 | var result = document.execCommand('copy');
338 | document.body.removeChild(input);
339 | return result;
340 | }
341 | document.body.appendChild(copyWrap)
342 | const copyBtn = document.querySelector('#copy-btn')
343 | copyBtn.onclick = () => {
344 | copy(document.cookie)
345 | copyBtn.innerText = '复制成功'
346 | copyBtn.style.background = 'green'
347 | }
348 | window.isAddCookieBtn = true
349 | }
350 | Object.assign({}, {isAddCookieBtn: window.isAddCookieBtn})
351 | `)) as {isAddCookieBtn: boolean}
352 | if (isAddCookieBtn) break
353 | await sleep(sleepTime)
354 | waitTimeCount += sleepTime
355 | }
356 | await webview.present()
357 | break
358 | case 1:
359 | const {texts: phoneInfo, cancel: phoneInfoCancel} = await showModal({
360 | title: '设置手机号和cookie',
361 | content: '请务必先登录,在登录处复制好 cookie 再来,不懂就仔细看登录说明',
362 | inputItems: [
363 | {
364 | text: getStorage('phoneNumber') || '',
365 | placeholder: '这里填你的手机号',
366 | },
367 | {
368 | text: getStorage('cookie') || '',
369 | placeholder: '这里填cookie',
370 | },
371 | ],
372 | })
373 | if (phoneInfoCancel) return
374 | setStorage('phoneNumber', phoneInfo[0])
375 | setStorage('cookie', phoneInfo[1])
376 | await showNotification({title: '设置完成', sound: 'default'})
377 | break
378 | case 2:
379 | const {texts, cancel} = await showModal({
380 | title: '设置全局背景和颜色',
381 | content: '如果为空,则还原默认',
382 | inputItems: [
383 | {
384 | text: getStorage('boxBg') || '',
385 | placeholder: '全局背景:可以是颜色、图链接',
386 | },
387 | {
388 | text: getStorage('textColor') || '',
389 | placeholder: '这里填文字颜色',
390 | },
391 | ],
392 | })
393 | if (cancel) return
394 | setStorage('boxBg', texts[0])
395 | setStorage('textColor', texts[1])
396 | await showNotification({title: '设置完成', sound: 'default'})
397 | break
398 | case 3:
399 | const img: Image | null = (await setTransparentBackground()) || null
400 | if (img) {
401 | setStorage('transparentBg', img)
402 | setStorage('boxBg', '透明背景')
403 | await showNotification({title: '设置透明背景成功', sound: 'default'})
404 | }
405 | break
406 | case 4:
407 | await showPreviewOptions(this.render.bind(this))
408 | break
409 | }
410 | }
411 |
412 | // 获取手机卡数据
413 | async getUserfulPhoneData(phoneNumber: string): Promise {
414 | if (!phoneNumber) return '请设置手机号'
415 | if (!isLaunchInsideApp() && !getStorage('cookie')) return 'cookie 不存在,请先登录'
416 | const api = `https://wap.10010.com/mobileService/home/queryUserInfoSeven.htm?version=iphone_c@7.0403&desmobiel=${phoneNumber}&showType=3`
417 | // 获取手机卡信息列表
418 | const res = await request({
419 | url: api,
420 | dataType: 'text',
421 | header: {
422 | 'user-agent':
423 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1',
424 | cookie: getStorage('cookie') || '',
425 | },
426 | })
427 | // isLaunchInsideApp() && cookie && setStorage('cookie', cookie)
428 | let usefulPhoneDatas: UsefulPhoneData[] = []
429 | try {
430 | const phoneDatas: PhoneData[] = (JSON.parse(res.data || '') as PhoneDatas).data.dataList || []
431 | // 提取有用的信息
432 | usefulPhoneDatas = phoneDatas.map(info => {
433 | const percent = info.usedTitle.replace(/(已用|剩余)([\d\.]+)?\%/, (...args) => {
434 | return args[1] === '剩余' ? args[2] : 100 - args[2]
435 | })
436 | return {
437 | type: info.type,
438 | percent: Number(percent) > 100 ? 100 : Number(percent),
439 | unit: info.unit,
440 | count: Number(info.number),
441 | label: info.remainTitle,
442 | }
443 | })
444 | } catch (err) {
445 | console.warn(`获取联通卡信息失败: ${err}`)
446 | await showNotification({title: '获取联通卡信息失败', body: '检查一下网络,或重新登录', sound: 'failure'})
447 | return '获取联通卡信息失败\n检查一下网络,或重新登录'
448 | }
449 | return usefulPhoneDatas
450 | }
451 | }
452 |
453 | EndAwait(() => new China10010().init())
454 |
--------------------------------------------------------------------------------
/src/scripts/funds.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * 基金小部件
3 | */
4 |
5 | import {
6 | isLaunchInsideApp,
7 | request,
8 | ResponseType,
9 | setTransparentBackground,
10 | showActionSheet,
11 | showModal,
12 | showNotification,
13 | showPreviewOptions,
14 | useStorage,
15 | } from '@app/lib/help'
16 |
17 | interface FundInfo {
18 | /**基金代码*/
19 | FCODE: string
20 |
21 | /**基金名字*/
22 | SHORTNAME: string
23 |
24 | /**日期*/
25 | PDATE: string
26 |
27 | /**单位净值数值*/
28 | NAV: string
29 |
30 | /**累计净值数值*/
31 | ACCNAV: string
32 |
33 | /**单位净值涨跌百分比*/
34 | NAVCHGRT: string
35 |
36 | /**单位净值估算数值*/
37 | GSZ: string
38 |
39 | /**单位净值估算涨跌百分比*/
40 | GSZZL: string
41 |
42 | /**更新时间*/
43 | GZTIME: string
44 | NEWPRICE: string
45 | CHANGERATIO: string
46 | ZJL: string
47 | HQDATE: string
48 | ISHAVEREDPACKET: boolean
49 | }
50 |
51 | interface FundsData {
52 | Datas: FundInfo[]
53 | ErrCode: number
54 | Success: boolean
55 | ErrMsg: null | string
56 | Message: null | string
57 | ErrorCode: string
58 | ErrorMessage: null | string
59 | ErrorMsgLst: null | string
60 | TotalCount: number
61 | Expansion: {
62 | GZTIME: string
63 | FSRQ: string
64 | }
65 | }
66 |
67 | const {setStorage, getStorage} = useStorage('Funds')
68 | const transparentBg: Image | string = getStorage('transparentBg') || '#fff'
69 | const textColor = getStorage('textColor') || (transparentBg === '#fff' ? '#000' : '#fff')
70 | const bgColor = getStorage('bgColor') || '#ffffff00'
71 | const fundsCode = getStorage('fundsCode') || []
72 |
73 | /**生成表格列组件*/
74 | function getFundColumn(fundsInfo: FundInfo[], title: string, keyName: keyof FundInfo) {
75 | // 数值个性化
76 | function showValue(value: FundInfo[keyof FundInfo]): Partial> {
77 | return {
78 | // 涨跌幅文字特别处理(涨绿,跌红)
79 | GSZZL: (
80 |
81 | {value + '%'}
82 |
83 | ),
84 | }
85 | }
86 | return (
87 |
88 |
89 | {title}
90 |
91 | {fundsInfo.map(fundInfo => {
92 | const value = fundInfo[keyName]
93 | return (
94 | <>
95 |
96 | {showValue(value)[keyName] || (
97 |
98 | {value}
99 |
100 | )}
101 | >
102 | )
103 | })}
104 |
105 | )
106 | }
107 |
108 | class Funds {
109 | async init() {
110 | if (isLaunchInsideApp()) {
111 | return await this.showMenu()
112 | }
113 | const widget = (await this.render()) as ListWidget
114 | Script.setWidget(widget)
115 | Script.complete()
116 | }
117 |
118 | //渲染组件
119 | async render(): Promise {
120 | let fundsInfo: FundInfo[] = []
121 | try {
122 | const result = (await this.getFundsData(this.getFundsCode())).data as FundsData
123 | fundsInfo = result.Datas || []
124 | } catch (err) {}
125 |
126 | return (
127 |
128 |
129 |
130 | {getFundColumn(fundsInfo, '基金名字', 'SHORTNAME')}
131 |
132 | {getFundColumn(fundsInfo, '估算净值', 'GSZ')}
133 |
134 | {getFundColumn(fundsInfo, '涨跌幅', 'GSZZL')}
135 |
136 |
137 |
138 |
139 |
140 | 更新时间: {new Date().toLocaleTimeString('chinese', {hour12: false})}
141 |
142 |
143 |
144 |
145 |
146 | )
147 | }
148 |
149 | // 显示菜单
150 | async showMenu() {
151 | const selectIndex = await showActionSheet({
152 | title: '菜单',
153 | itemList: ['设置基金代码', '预览组件', '设置透明背景', '设置文字和背景颜色'],
154 | })
155 | switch (selectIndex) {
156 | case 0:
157 | const {texts: fundsCodeTexts} = await showModal({
158 | title: '设置基金代码',
159 | content: '最多只能设置9个,在中尺寸下只显示前3个, 多个代码用逗号隔开。例如:002207, 002446, 161005',
160 | inputItems: [
161 | {
162 | text: (getStorage('fundsCode') || []).join(', '),
163 | placeholder: '例如:002207, 002446, 161005',
164 | },
165 | ],
166 | })
167 | if (fundsCodeTexts[0]) {
168 | const fundsCode = fundsCodeTexts[0].match(/[\d]{6}/g) || []
169 | setStorage('fundsCode', fundsCode)
170 | }
171 | break
172 | case 1:
173 | await showPreviewOptions(this.render.bind(this))
174 | break
175 | case 2:
176 | const img: Image | null = (await setTransparentBackground()) || null
177 | img && setStorage('transparentBg', img)
178 | img && (await showNotification({title: '设置透明背景成功', sound: 'default'}))
179 | break
180 | case 3:
181 | const {texts} = await showModal({
182 | title: '设置文字和背景颜色',
183 | content: '黑色是#000,白色是#fff,半透明白是 #ffffff88, 半透明黑是 #00000088',
184 | inputItems: [
185 | {
186 | placeholder: `文字颜色,${
187 | getStorage('textColor') ? '当前是' + getStorage('textColor') + ',' : ''
188 | }默认黑色#000`,
189 | },
190 | {
191 | placeholder: `背景颜色,${
192 | getStorage('bgColor') ? '当前是' + getStorage('bgColor') + ',' : ''
193 | }默认白色#fff`,
194 | },
195 | ],
196 | })
197 | if (texts[0]) setStorage('textColor', texts[0])
198 | if (texts[1]) setStorage('bgColor', texts[1])
199 | break
200 | }
201 | }
202 | // 获取基金代码数组
203 | getFundsCode(): string[] {
204 | // 大号组件显示9个
205 | // 中号组件显示3个
206 | const defaultFundsCode = ['002207', '002446', '161005', '163406', '008282', '001790', '008641', '001838', '001475']
207 | const _fundsCode = fundsCode.length > 0 ? fundsCode : defaultFundsCode
208 | return config.widgetFamily === 'medium' ? _fundsCode.slice(0, 3) : _fundsCode.slice(0, 9)
209 | }
210 |
211 | // 生成 device id
212 | getDeviceId(): string {
213 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
214 | const r = (Math.random() * 16) | 0
215 | const v = c == 'x' ? r : (r & 0x3) | 0x8
216 | return v.toString(16)
217 | })
218 | }
219 |
220 | // 获取基金数据
221 | async getFundsData(fundsId: string[]): Promise> {
222 | return request({
223 | url: `https://fundmobapi.eastmoney.com/FundMNewApi/FundMNFInfo?pageIndex=1&pageSize=100&plat=Android&appType=ttjj&product=EFund&Version=1&deviceid=${this.getDeviceId()}&Fcodes=${fundsId.join(
224 | ',',
225 | )}`,
226 | dataType: 'json',
227 | })
228 | }
229 | }
230 |
231 | EndAwait(() => new Funds().init())
232 |
--------------------------------------------------------------------------------
/src/scripts/helloWorld.tsx:
--------------------------------------------------------------------------------
1 | class HelloWorld {
2 | async init() {
3 | // ListWidget 实例
4 | const widget = (await this.render()) as ListWidget
5 | // 注册小部件
6 | Script.setWidget(widget)
7 | // 调试用
8 | !config.runsInWidget && (await widget.presentMedium())
9 | // 脚本结束
10 | Script.complete()
11 | }
12 | async render(): Promise {
13 | return (
14 |
15 |
16 |
17 | Hello World
18 |
19 |
20 |
21 | )
22 | }
23 | }
24 |
25 | // 从 process.env 下可以加载 .env 文件的键值对
26 | // 详见 https://github.com/2214962083/ios-scriptable-tsx/blob/master/docs/config.md#env-config
27 | console.log(process.env.HELLO + ',' + process.env.MOMENT)
28 |
29 | new HelloWorld().init()
30 |
--------------------------------------------------------------------------------
/src/scripts/launcher.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * 个性磁铁小部件
3 | */
4 |
5 | import {
6 | isLaunchInsideApp,
7 | setTransparentBackground,
8 | showActionSheet,
9 | showModal,
10 | showNotification,
11 | showPreviewOptions,
12 | useStorage,
13 | } from '@app/lib/help'
14 | import {Col} from '@app/lib/components'
15 | import {WstackProps} from '@app/types/widget'
16 | import {FC} from 'react'
17 |
18 | const {setStorage, getStorage} = useStorage('luancher-xiaoming')
19 |
20 | /**文字颜色*/
21 | const textColor = getStorage('textColor') || '#ffffff'
22 |
23 | /**所有格子统一颜色*/
24 | const gridColor: string | null = getStorage('gridColor')
25 |
26 | /**背景颜色或背景图链接*/
27 | const boxBg = getStorage('boxBg') || '#222222'
28 |
29 | /**透明背景*/
30 | const transparentBg: Image | string = getStorage('transparentBg') || '#222222'
31 |
32 | /**格子间隔*/
33 | const space = getStorage('space') || 1
34 |
35 | // 好看的颜色
36 | const colors = {
37 | red: '#e54d42',
38 | orange: '#f37b1d',
39 | yellow: '#fbbd08',
40 | olive: '#8dc63f',
41 | green: '#39b54a',
42 | cyan: '#1cbbb4',
43 | blue: '#0081ff',
44 | purple: '#6739b6',
45 | mauve: '#9c26b0',
46 | pink: '#e03997',
47 | brown: '#a5673f',
48 | grey: '#8799a3',
49 | black: '#000000',
50 | }
51 |
52 | /**格子组件参数*/
53 | interface GridProps {
54 | /** icon 名字*/
55 | iconName?: string
56 |
57 | /**格子背景*/
58 | background?: WstackProps['background']
59 |
60 | /**文字*/
61 | text?: string
62 |
63 | /**点击格子跳转的链接*/
64 | href?: string
65 | }
66 |
67 | /**格子组件*/
68 | const Grid: FC = ({...props}) => {
69 | const {iconName, background, text, href} = props
70 | const bgColors: string[] = Object.values(colors)
71 | const bgRandom = Math.floor(Math.random() * bgColors.length)
72 | const bgColor = bgColors[bgRandom]
73 | return (
74 |
75 |
80 | {iconName && }
81 |
82 | {(!iconName || text) && (
83 |
84 | {text || ''}
85 |
86 | )}
87 |
88 |
89 | )
90 | }
91 |
92 | /**间隔组件*/
93 | const Space: FC = () =>
94 |
95 | class Launcher {
96 | private config: GridProps[]
97 | constructor(config: GridProps[]) {
98 | this.config = config
99 | }
100 | async init() {
101 | if (isLaunchInsideApp()) {
102 | return await this.showMenu()
103 | }
104 | const widget = (await this.render()) as ListWidget
105 | Script.setWidget(widget)
106 | Script.complete()
107 | }
108 |
109 | //渲染组件
110 | async render(): Promise {
111 | if (isLaunchInsideApp()) {
112 | await showNotification({title: '稍等片刻', body: '小部件渲染中...', sound: 'alert'})
113 | }
114 | // 多久(毫秒)更新一次小部件(默认24小时)
115 | const updateInterval = 24 * 60 * 60 * 1000
116 | // 渲染尺寸
117 | const size = config.widgetFamily
118 | return (
119 |
125 | {size === 'small' && this.renderSmall()}
126 | {size === 'medium' && this.renderMedium()}
127 | {size === 'large' && this.renderLarge()}
128 |
129 | )
130 | }
131 |
132 | // 渲染小尺寸
133 | renderSmall() {
134 | return (
135 |
136 |
137 |
138 |
139 |
140 | )
141 | }
142 |
143 | // 渲染中尺寸
144 | renderMedium() {
145 | return (
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 | )
164 | }
165 |
166 | // 渲染大尺寸
167 | renderLarge() {
168 | return (
169 |
170 | {/* 上半部分 */}
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 | {/* 下半部分 */}
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 | )
213 | }
214 |
215 | // 显示菜单
216 | async showMenu() {
217 | const selectIndex = await showActionSheet({
218 | title: '菜单',
219 | itemList: ['设置全局背景和颜色', '设置透明背景', '预览组件'],
220 | })
221 | switch (selectIndex) {
222 | case 0:
223 | const {texts, cancel} = await showModal({
224 | title: '设置全局背景和颜色',
225 | content:
226 | '此处不能修改单个格子的风格,只能统一覆盖,如果为空,则还原默认,只有在设置格子半透明下(如#00000055)才能看到背景',
227 | inputItems: [
228 | {
229 | text: getStorage('boxBg') || '',
230 | placeholder: '这里填全局背景,可以是颜色、图片链接',
231 | },
232 | {
233 | text: getStorage('gridColor') || '',
234 | placeholder: '这里填所有格子颜色',
235 | },
236 | {
237 | text: getStorage('textColor') || '',
238 | placeholder: '这里填文字颜色',
239 | },
240 | {
241 | text: String(getStorage('space')) || '',
242 | placeholder: '格子间间隔,默认是1',
243 | },
244 | ],
245 | })
246 | if (cancel) return
247 | setStorage('boxBg', texts[0])
248 | setStorage('gridColor', texts[1])
249 | setStorage('textColor', texts[2])
250 | setStorage('space', texts[3])
251 | await showNotification({title: '设置完成', sound: 'default'})
252 | break
253 | case 1:
254 | const img: Image | null = (await setTransparentBackground()) || null
255 | if (img) {
256 | setStorage('transparentBg', img)
257 | setStorage('boxBg', '透明背景')
258 | await showNotification({title: '设置透明背景成功', sound: 'default'})
259 | }
260 | break
261 | case 2:
262 | await showPreviewOptions(this.render.bind(this))
263 | break
264 | }
265 | }
266 | }
267 |
268 | const luancherConfig: GridProps[] = [
269 | {
270 | href: 'weixin://scanqrcode',
271 | background: colors.green,
272 | iconName: 'barcode.viewfinder',
273 | text: '微信扫一扫',
274 | },
275 | {
276 | href: 'alipayqr://platformapi/startapp?saId=10000007',
277 | background: colors.blue,
278 | iconName: 'barcode.viewfinder',
279 | text: '支付宝',
280 | },
281 | {
282 | href: 'weixin://',
283 | background: colors.green,
284 | iconName: 'message.fill',
285 | text: '微信',
286 | },
287 | {
288 | href: 'orpheuswidget://',
289 | background: colors.red,
290 | iconName: 'music.note',
291 | text: '网易云',
292 | },
293 | {
294 | href: 'alipay://platformapi/startapp?appId=20000056',
295 | background: colors.blue,
296 | iconName: 'qrcode',
297 | text: '付款码',
298 | },
299 | {
300 | href: 'mqq://',
301 | background: colors.blue,
302 | iconName: 'paperplane.fill',
303 | text: 'QQ',
304 | },
305 | {
306 | href: 'sinaweibo://',
307 | background: colors.red,
308 | iconName: 'eye',
309 | text: '微博',
310 | },
311 | {
312 | href: 'bilibili://',
313 | background: colors.pink,
314 | iconName: 'tv',
315 | text: '哔哩哔哩',
316 | },
317 | {
318 | href: 'zhihu://',
319 | //background: colors.blue,
320 | background: `https://bing.ioliu.cn/v1/rand?w=600&h=200×tamp=${Date.now()}`,
321 | iconName: 'questionmark',
322 | text: '知乎',
323 | },
324 | {
325 | href: 'iqiyi://',
326 | background: colors.green,
327 | iconName: 'film',
328 | text: '爱奇艺',
329 | },
330 | {
331 | href: 'tencentlaunch1104466820://',
332 | background: colors.orange,
333 | iconName: 'gamecontroller',
334 | text: '王者',
335 | },
336 | ]
337 | EndAwait(() => new Launcher(luancherConfig).init())
338 |
--------------------------------------------------------------------------------
/src/scripts/music163.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * 网易云歌单小部件
3 | */
4 |
5 | import {
6 | getRandomInt,
7 | isLaunchInsideApp,
8 | request,
9 | showActionSheet,
10 | showModal,
11 | showNotification,
12 | showPreviewOptions,
13 | useStorage,
14 | } from '@app/lib/help'
15 | import {Col} from '@app/lib/components'
16 | import {WstackProps} from '@app/types/widget'
17 | import {FC} from 'react'
18 |
19 | /**网易云歌单数据格式*/
20 | interface NetmusicListData {
21 | code: number
22 | playlist: Playlist
23 | }
24 |
25 | interface Playlist {
26 | /**歌曲信息存放*/
27 | tracks: Track[]
28 | }
29 |
30 | interface Track {
31 | name: string
32 | id: number
33 | pst: number
34 | t: number
35 | alia: string[]
36 | pop: number
37 | st: number
38 | rt?: string
39 | fee: number
40 | v: number
41 | crbt?: unknown
42 | cf: string
43 |
44 | /**该首歌曲信息*/
45 | al: MusicInfo
46 | dt: number
47 | a?: unknown
48 | cd: string
49 | no: number
50 | rtUrl?: unknown
51 | ftype: number
52 | rtUrls: unknown[]
53 | djId: number
54 | copyright: number
55 | s_id: number
56 | mark: number
57 | originCoverType: number
58 | noCopyrightRcmd?: unknown
59 | rtype: number
60 | rurl?: unknown
61 | mst: number
62 | cp: number
63 | mv: number
64 | publishTime: number
65 | alg: string
66 | }
67 |
68 | /**歌曲信息*/
69 | interface MusicInfo {
70 | /**歌曲 id */
71 | id: number
72 |
73 | /**歌曲名字*/
74 | name: string
75 |
76 | /**歌曲封面图*/
77 | picUrl: string
78 |
79 | tns: unknown[]
80 | pic_str: string
81 | pic: number
82 | }
83 |
84 | const {setStorage, getStorage} = useStorage('music163-grid')
85 |
86 | // Favorite 歌单ID
87 | const favoriteListId = getStorage('favoriteListId') || 3136952023
88 |
89 | // Like 歌单ID
90 | const likeListId = getStorage('likeListId') || 310970433
91 |
92 | // Cloud 歌单ID
93 | const cloudListId = getStorage('cloudListId') || 2463071445
94 |
95 | // 文字颜色
96 | const textColor = '#ffffff'
97 |
98 | /**格子组件参数*/
99 | interface GridProps {
100 | /** icon 名字*/
101 | iconName: string
102 |
103 | /**格子背景*/
104 | background: WstackProps['background']
105 |
106 | /**文字*/
107 | text: string
108 |
109 | /**点击格子跳转的链接*/
110 | href: string
111 | }
112 |
113 | /**格子组件*/
114 | const Grid: FC = ({...props}) => {
115 | const {iconName, background, text, href} = props
116 | return (
117 |
118 |
119 |
120 |
121 | {text}
122 |
123 |
124 |
125 | )
126 | }
127 |
128 | class Music163 {
129 | async init() {
130 | if (isLaunchInsideApp()) {
131 | return await this.showMenu()
132 | }
133 | const widget = (await this.render()) as ListWidget
134 | Script.setWidget(widget)
135 | Script.complete()
136 | }
137 |
138 | //渲染组件
139 | async render(): Promise {
140 | if (isLaunchInsideApp()) {
141 | await showNotification({title: '稍等片刻', body: '小部件渲染中...', sound: 'alert'})
142 | }
143 | const favoriteImageUrl = ((await this.getRandomMusic(favoriteListId)) || {}).picUrl
144 | const likeImageUrl = ((await this.getRandomMusic(likeListId)) || {}).picUrl
145 | const cloudImageUrl = ((await this.getRandomMusic(cloudListId)) || {}).picUrl
146 | // 多久(毫秒)更新一次小部件(默认3小时)
147 | const updateInterval = 3 * 60 * 60 * 1000
148 | return (
149 |
150 |
151 |
157 |
158 |
159 |
165 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 | )
180 | }
181 |
182 | // 显示菜单
183 | async showMenu() {
184 | const selectIndex = await showActionSheet({
185 | title: '菜单',
186 | itemList: ['自定义歌单', '预览组件'],
187 | })
188 | let musicListId: number | null
189 | switch (selectIndex) {
190 | case 0:
191 | const {texts} = await showModal({
192 | title: '设置歌单',
193 | content: '去网易云歌单 -> 分享 -> 复制链接, 然后粘贴到此',
194 | inputItems: [
195 | {
196 | placeholder: '这里填 Favorite 的歌单链接',
197 | },
198 | {
199 | placeholder: '这里填 Like 的歌单链接',
200 | },
201 | {
202 | placeholder: '这里填 Cloud 的歌单链接',
203 | },
204 | ],
205 | })
206 | if (texts[0]) {
207 | musicListId = this.getListIdFromLink(texts[0])
208 | musicListId && setStorage('favoriteListId', musicListId)
209 | !musicListId &&
210 | (await showNotification({
211 | title: '歌单链接错误',
212 | body: 'Favorite 的歌单链接检测不到歌单 id ',
213 | sound: 'failure',
214 | }))
215 | }
216 | if (texts[1]) {
217 | musicListId = this.getListIdFromLink(texts[1])
218 | musicListId && setStorage('likeListId', musicListId)
219 | !musicListId &&
220 | (await showNotification({title: '歌单链接错误', body: 'Like 的歌单链接检测不到歌单 id ', sound: 'failure'}))
221 | }
222 | if (texts[2]) {
223 | musicListId = this.getListIdFromLink(texts[2])
224 | musicListId && setStorage('cloudListId', musicListId)
225 | !musicListId &&
226 | (await showNotification({
227 | title: '歌单链接错误',
228 | body: 'cloud 的歌单链接检测不到歌单 id ',
229 | sound: 'failure',
230 | }))
231 | }
232 | await showNotification({title: '设置完成', sound: 'default'})
233 | break
234 | case 1:
235 | await showPreviewOptions(this.render.bind(this))
236 | break
237 | }
238 | }
239 | /**
240 | * 根据歌单链接获取歌单 id
241 | * @param musicListLink 歌单链接
242 | */
243 | getListIdFromLink(musicListLink: string): number | null {
244 | return Number((musicListLink.match(/\&id\=([\d]+)/) || [])[1]) || null
245 | }
246 |
247 | /**
248 | * 根据歌单id获取歌单数据
249 | * @param musicListId 歌单 id
250 | **/
251 | async getMusicListData(musicListId: number): Promise