diff --git a/.gitignore b/.gitignore index 829fff8..07061bf 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ app.*.map.json /android/app/release /backup -#assets/xa* \ No newline at end of file + +assets/xa* diff --git a/README.md b/README.md index 4286fd4..f12ab9e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # 小小电脑 - + -点开软件就是电脑 +即开即用的类PC环境,内置WPS、火狐浏览器、VS Code等常用软件 -Click-to-run debian 12 xfce on android for Chinese users, with fcitx pinyin input method and wps office preinstalled. No termux required. +Click-to-run ubuntu jammy xfce on android for Chinese users, with fcitx pinyin input method and wps office preinstalled. No termux required. ## 原理 -使用proot运行debian环境 +使用proot运行ubuntu环境 内置[noVNC](https://github.com/novnc/noVNC)显示图形界面 @@ -17,49 +17,47 @@ Click-to-run debian 12 xfce on android for Chinese users, with fcitx pinyin inpu 只支持arm64安卓 -**目前新安装的软件无法读写文件,但可以访问手机存储,原因未知** - -(我接下来可能会排查一下是proot还是容器的问题 -顺便学习一下容器是怎么做的 -毕竟我的修改可能出了问题) - ## 项目结构 assets的文件来源如下: -- [build-proot-android, proot二进制文件](https://github.com/green-green-avk/build-proot-android) +- [proot](https://github.com/termux/proot/), 使用[build-proot-android](https://github.com/green-green-avk/build-proot-android)脚本编译 - [busybox](https://github.com/meefik/busybox) - [Xserver XSDL, pulseaudio相关文件](https://github.com/pelya/commandergenius/tree/sdl_android/project/jni/application/xserver) -- [Tmoe Linux, debian包来源](https://github.com/2moe/tmoe) +- [proot-distro, ubuntu包来源](https://github.com/termux/proot-distro) -其中proot、busybox和pulseaudio相关文件都是直接用了二进制文件。 +其中busybox和pulseaudio相关文件都是直接用了二进制文件。 (pulseaudio我真的编译不来,如果你会的话请教教我吧) -对debian容器进行了如下修改: -- 使用tmoe工具安装了xfce环境和全套VNC; +对ubuntu容器进行了如下修改: +- 安装了xfce环境、tigerVNC、noVNC; +- 使用kali-undercover提供的Win10主题美化xfce; - 安装了wps office, 对wps office进行了如下修改: - 界面改成了多组件,避免无法打开wps; - - 根据[这篇文章](https://forums.debiancn.org/t/topic/4015/8)创建了libtiff软链,避免无法打开wpspdf - 补上了缺失的字体; - 安装了VS Code和中文插件; - 安装了fcitx输入法和云拼音组件。按切换输入法。 - 强烈建议**不要**使用安卓中文输入法直接输入中文,而是使用英文键盘通过容器的输入法输入中文,避免丢字错字。 -- 对VNC启动脚本进行修改,删除了tigerVNC密码验证; - - 虽然不太可能,但如果还是被问到密码的话输12345678 -- 对noVNC脚本(/usr/local/etc/tmoe-linux/novnc/core/rfb.js)进行修改,添加了userScale变量控制缩放 - - 默认显示太大了,很多窗口点开都超出了屏幕范围,目前我使显示缩小了userScale=1.5倍 -- 改掉了一些容器里的Termux硬链接,有一些.git文件夹里的没改,应该无伤大雅吧=v= +- 对noVNC进行[修改](https://github.com/Cateners/noVNC) (scale_factor分支),添加了scale factor滑块控制缩放 +- 在主目录下提供storage文件夹,通过此文件夹可以方便地访问手机存储(如果提供了存储权限的话) +- 启动时会尝试挂载手机的一些字体目录(AppFiles/Fonts、Fonts和/system/fonts), 如果这些目录下有字体文件的话会一并加载到系统中,无需额外安装 - 最后采用tar.xz压缩,用split命令分成了xa*等多个文件 +数据包不再在assets中更新,而是随releases提供,主要是为了避免git越来越大 + lib目录: -- main.dart文件,页面布局,目前只有一个页面,非常简单 +- main.dart文件,页面布局,老实说已经有点乱了 - workflow.dart文件,逻辑部分,目前也还算简单 - Util 工具类 - G 全局变量类 - Workflow 从软件点开到容器启动的所有步骤 +## 目前已知bug + +多用户/分身情形无法使用apt, 其它见issue + ## 一些链接 这是我的第一个flutter软件,感谢这些项目为我指路 diff --git a/android/gradle.properties b/android/gradle.properties index 710351d..e45b3ce 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ #org.gradle.jvmargs=-Xmx1536M -org.gradle.jvmargs=-Xmx12288m +org.gradle.jvmargs=-Xmx16384m android.useAndroidX=true android.enableJetifier=true diff --git a/assets/assets.zip b/assets/assets.zip index d6a1645..c198b22 100644 Binary files a/assets/assets.zip and b/assets/assets.zip differ diff --git a/assets/xaa b/assets/xaa deleted file mode 100644 index 75cc9fb..0000000 Binary files a/assets/xaa and /dev/null differ diff --git a/assets/xab b/assets/xab deleted file mode 100644 index 217b419..0000000 Binary files a/assets/xab and /dev/null differ diff --git a/assets/xac b/assets/xac deleted file mode 100644 index e5af911..0000000 Binary files a/assets/xac and /dev/null differ diff --git a/assets/xad b/assets/xad deleted file mode 100644 index 477a56e..0000000 Binary files a/assets/xad and /dev/null differ diff --git a/assets/xae b/assets/xae deleted file mode 100644 index 2a848da..0000000 Binary files a/assets/xae and /dev/null differ diff --git a/assets/xaf b/assets/xaf deleted file mode 100644 index 66c47b0..0000000 Binary files a/assets/xaf and /dev/null differ diff --git a/assets/xag b/assets/xag deleted file mode 100644 index 288f2a7..0000000 Binary files a/assets/xag and /dev/null differ diff --git a/assets/xah b/assets/xah deleted file mode 100644 index d876b22..0000000 Binary files a/assets/xah and /dev/null differ diff --git a/assets/xai b/assets/xai deleted file mode 100644 index a2bc6a0..0000000 Binary files a/assets/xai and /dev/null differ diff --git a/assets/xaj b/assets/xaj deleted file mode 100644 index 6c53cf6..0000000 Binary files a/assets/xaj and /dev/null differ diff --git a/assets/xak b/assets/xak deleted file mode 100644 index f908fed..0000000 Binary files a/assets/xak and /dev/null differ diff --git a/assets/xal b/assets/xal deleted file mode 100644 index 8074a2f..0000000 Binary files a/assets/xal and /dev/null differ diff --git a/assets/xam b/assets/xam deleted file mode 100644 index 05f56f9..0000000 Binary files a/assets/xam and /dev/null differ diff --git a/assets/xan b/assets/xan deleted file mode 100644 index 1f9a2a9..0000000 Binary files a/assets/xan and /dev/null differ diff --git a/assets/xao b/assets/xao deleted file mode 100644 index 771ad94..0000000 Binary files a/assets/xao and /dev/null differ diff --git a/lib/main.dart b/lib/main.dart index 9de6457..397d93d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,9 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; +//import 'dart:convert'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter/material.dart'; @@ -25,6 +27,8 @@ import 'package:xterm/xterm.dart'; //import 'package:xterm/flutter.dart'; import 'package:tiny_computer/workflow.dart'; +import 'package:unity_ads_plugin/unity_ads_plugin.dart'; + void main() { runApp(const MyApp()); } @@ -89,7 +93,7 @@ class InfoPage extends StatefulWidget { } class _InfoPageState extends State { - final List _expandState = [true, false, false, false]; + final List _expandState = [false, false, false, false]; @override Widget build(BuildContext context) { return ExpansionPanelList( @@ -106,44 +110,95 @@ class _InfoPageState extends State { return const ListTile(title: Text("使用说明")); }, body: const Padding(padding: EdgeInsets.all(8), child: Text(""" -Loading... Please wait about 5 to 10 minutes for first time -第一次加载,大概需要几分钟... +第一次加载, 大概需要5到10分钟... 请不要在安装时退出软件 -你可以在等待的同时阅读“隐私政策”和“服务条款”, -阅读完后可以关闭广告 -...不过我还没写(广告也没放) 一些注意事项: -此软件免费开源 -项目地址: https://github.com/Cateners/tiny_computer -如果是买的就是被骗了, 请举报! -((然后请我喝水!!!!!!)(不是)) +此软件以GPL协议免费开源 +如果是买的就是被骗了, 请举报 +源代码在这里: https://github.com/Cateners/tiny_computer +软件也会第一时间在这里更新 +请尽可能在这里下载软件, 确保是正版 -如果遇到android 12的signal 9问题 -请自行查找教程修复 +常见问题: +如果你的系统版本大于等于android 12 +可能会在使用过程中异常退出(返回错误码9) +届时本软件会提供方案指引你修复 并不难 此软件因为没有权限 所以不能帮你修复 -一般只要你以前修复过(Tmoe脚本、Vmos助手、全手动adb等等) -现在就不用再次修复 + +如果你给了存储权限 +那么可以从主目录下的 +storage目录访问手机存储 + +如果认为界面大小比例不合适 +可以通过调整左栏设置-高级设置里的scale +快捷调整界面缩放 +这个功能是原本的noVNC里没有的哦! +具体的改动可以在这里看到: +https://github.com/Cateners/noVNC/tree/scale_factor + +其余两个选项是 +quality(图像质量)和compression(压缩等级) +...是noVNC中本来就有的选项。 +如果感觉界面卡卡的 +可以适当调低 + +如果你想安装其他软件 +可以在网上搜索 +"ubuntu安装xxx教程" +"linux安装xxx教程"等等 +本软件也提供一些基本软件安装按钮 +包括图形处理, 视频剪辑, 科学计算相关的软件 +稍后你就会看到 + +如果你想安装更多字体 +在给了存储权限的情况下 +直接将字体复制到手机存储的Fonts文件夹即可 +一些常用的办公字体 +可以在Windows电脑的C:\\Windows\\Fonts文件夹找到 +由于可能的版权问题 +软件不能帮你做 + +关于中文输入的问题 +强烈建议不要使用安卓中文输入法直接输入中文 +而是使用英文键盘通过容器的输入法(Ctrl+空格切换)输入中文 +避免丢字错字 + +在之前的版本中有网友反馈过这些问题 +还请注意: +三星Galaxy S21 Ultra, 安卓13, 黑屏 +红米Note 12, 安卓13(miui14), 黑屏 +红米Note 11T Pro+, miui13.0.4,“无法连接” +Vivo Pad,安卓13,看不见鼠标移动 +关于这个 +我目前没有什么好的解决办法 +(毕竟我没有这些设备 +也不方便定位原因) +如果你遇到了类似问题 +不管解没解决 +都可以去https://github.com/Cateners/tiny_computer/issues/1留个言 + +感谢使用! + +项目原理: +项目采用proot运行ubuntu虚拟容器系统 +图形界面是经过kali-undercover提供的Win10主题美化的xfce +系统预装了WPS, VSCode、火狐浏览器和fcitx输入法 这个项目没有使用Termux 因为我不太喜欢Termux的路径硬编码 路径硬编码会导致软件在多用户/分身等场景无法使用 当然这样一来就用不了Termux的软件生态了 -项目采用proot运行tmoe的debian12(xfce) -debian系统里预装了WPS, VSCode和fcitx输入法 -界面是webview+noVNC - -目前不能安装其他软件 -安装的其他软件无法读写容器 -但可以访问手机存储 -我也不太清楚原因 - -如果你给了存储权限 -那么可以从storage目录访问手机目录 -所以任何时候都不要尝试rm -rf /* +...如果你不知道什么是Termux +那也无所谓 +即使完全不懂原理也不影响使用本软件 +但假如有一天你有了其他高级需求 +比如想换系统、换架构等等 +那么请学习并使用Termux +届时本软件的使命已经达成... (顺带一提, 全部解压完大概需要7GB空间 解压途中占用空间可能达到9GB @@ -170,7 +225,26 @@ debian系统里预装了WPS, VSCode和fcitx输入法 return const ListTile(title: Text("支持作者")); }), body: Column( children: [ - const Padding(padding: EdgeInsets.all(8), child: Text("请我喝一杯水吧")), + const Padding(padding: EdgeInsets.all(8), child: Text(""" +这个软件预计会有一些广告 +之前的版本中说过 +如果完整地看了"隐私政策"与"服务条款"的话 +就可以选择关闭广告 +但因为那两个玩意一直都不知道怎么写 +想想还是算了 +但软件里的广告还是可以关闭的 + +本软件的广告分为横幅广告和视频广告 +横幅广告在终端和控制页面的顶端出现 +只需完整观看一次视频广告即可永久关闭 +视频广告目前只在"关闭横幅广告"和"启用终端"两个功能中出现 +看一个视频即可永久启用对应功能 +我认为还是比较良心的...吧? + +总之为了良好的体验 +在图形界面是不会出现广告的 +这点还请放心 +""")), const FractionallySizedBox( widthFactor: 0.8, child: Image(image: AssetImage("images/alipay.png")) @@ -237,32 +311,254 @@ class MyHomePage extends StatefulWidget { class _MyHomePageState extends State { + //高级设置,全局设置 + final List _expandState = [false, false, false]; + + bool bannerAdsFailedToLoad = false; + //安装完成了吗? //完成后从加载界面切换到主界面 bool isLoadingComplete = false; //主界面索引 int pageIndex = 0; + final ButtonStyle buttonStyle = OutlinedButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(0, 0), padding: const EdgeInsets.fromLTRB(4, 2, 4, 2) + ); + @override Widget build(BuildContext context) { - G.homePageStateContext = context; + G.homePageStateContext = context; - if (!isLoadingComplete) { - Workflow.workflow().then((value) { - setState(() { - isLoadingComplete = true; + if (!isLoadingComplete) { + Workflow.workflow().then((value) { + setState(() { + isLoadingComplete = true; + }); }); - }); - } + } return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(widget.title), + title: Text(isLoadingComplete?Util.getCurrentProp("name"):widget.title), ), - body: isLoadingComplete?TerminalView(G.terminal):const LoadingPage(), + body: isLoadingComplete?Column(mainAxisSize: MainAxisSize.min, children: [ + G.prefs.getBool("isBannerAdsClosed")!||bannerAdsFailedToLoad?SizedBox.fromSize(size: const Size.square(0),):UnityBannerAd( + placementId: AdManager.bannerAdPlacementId, + onLoad: (placementId) => print('Banner loaded: $placementId'), + onClick: (placementId) => print('Banner clicked: $placementId'), + onFailed: (placementId, error, message) { + print('Banner Ad $placementId failed: $error $message'); + setState(() { + bannerAdsFailedToLoad = true; + }); + }, + ),Expanded(flex: 1, child: AnimatedSwitcher( + duration: const Duration(milliseconds: 256), + child: [TerminalView(G.termPtys[G.currentContainer]!.terminal), Padding( + padding: const EdgeInsets.all(8), + child: Scrollbar(child: SingleChildScrollView(child: Column( + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: FractionallySizedBox( + widthFactor: 0.4, + child: Image( + image: AssetImage("images/icon.png") + ) + ), + ), + /*Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), + child: Text(Util.getCurrentProp("name"), textScaleFactor: 2), + ),*/ + Wrap(alignment: WrapAlignment.center, spacing: 4.0, runSpacing: 4.0, children: Util.getCurrentProp("commands") + .asMap().entries.map((e) { + return OutlinedButton(style: buttonStyle, child: Text(e.value["name"]!), onPressed: () { + setState(() { + Util.termWrite(e.value["command"]!); + pageIndex = 0; + }); + }, onLongPress: () { + String name = e.value["name"]!; + String command = e.value["command"]!; + showDialog(context: context, builder: (context) { + return AlertDialog(title: const Text("指令编辑"), content: SingleChildScrollView(child: Column(children: [ + TextFormField(initialValue: name, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令名称"), onChanged: (value) { + name = value; + }), + SizedBox.fromSize(size: const Size.square(8)), + TextFormField(maxLines: null, initialValue: command, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令内容"), onChanged: (value) { + command = value; + }), + ])), actions: [ + TextButton(onPressed:() async { + await Util.setCurrentProp("commands", Util.getCurrentProp("commands") + ..removeAt(e.key)); + setState(() {}); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, child: const Text("删除该项")), + TextButton(onPressed:() { + Navigator.of(context).pop(); + }, child: const Text("取消")), + TextButton(onPressed:() async { + await Util.setCurrentProp("commands", Util.getCurrentProp("commands") + ..setAll(e.key, [{"name": name, "command": command}])); + setState(() {}); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, child: const Text("保存")), + ]); + },); + },); + }).toList()..add(OutlinedButton(style: buttonStyle, onPressed:() { + String name = ""; + String command = ""; + showDialog(context: context, builder: (context) { + return AlertDialog(title: const Text("指令编辑"), content: SingleChildScrollView(child: Column(children: [ + TextFormField(initialValue: name, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令名称"), onChanged: (value) { + name = value; + }), + SizedBox.fromSize(size: const Size.square(8)), + TextFormField(maxLines: null, initialValue: command, decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "指令内容"), onChanged: (value) { + command = value; + }), + ])), actions: [ + TextButton(onPressed:() { + Navigator.of(context).pop(); + }, child: const Text("取消")), + TextButton(onPressed:() async { + await Util.setCurrentProp("commands", Util.getCurrentProp("commands") + ..add({"name": name, "command": command})); + setState(() {}); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, child: const Text("添加")), + ]); + },); + }, child: const Text("添加快捷指令")))), + Padding(padding: const EdgeInsets.all(8), child: Card(child: Padding(padding: const EdgeInsets.all(8), child: + Column(children: [ + ExpansionPanelList( + elevation: 1, + expandedHeaderPadding: const EdgeInsets.all(0), + expansionCallback: (panelIndex, isExpanded) { + setState(() { + _expandState[panelIndex] = isExpanded; + }); + },children: [ + ExpansionPanel( + isExpanded: _expandState[0], + headerBuilder: ((context, isExpanded) { + return const ListTile(title: Text("高级设置"), subtitle: Text("修改后重启生效")); + }), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [ + TextFormField(maxLines: null, initialValue: Util.getCurrentProp("name"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "容器名称"), onChanged: (value) async { + await Util.setCurrentProp("name", value); + setState(() {}); + }), + SizedBox.fromSize(size: const Size.square(8)), + TextFormField(maxLines: null, initialValue: Util.getCurrentProp("boot"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "启动命令"), onChanged: (value) async { + await Util.setCurrentProp("boot", value); + }), + SizedBox.fromSize(size: const Size.square(8)), + TextFormField(maxLines: null, initialValue: Util.getCurrentProp("vnc"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "vnc启动命令"), onChanged: (value) async { + await Util.setCurrentProp("vnc", value); + }), + SizedBox.fromSize(size: const Size.square(8)), + TextFormField(maxLines: null, initialValue: Util.getCurrentProp("vncUrl"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "网页跳转地址"), onChanged: (value) async { + await Util.setCurrentProp("vncUrl", value); + }), + ],))), + ExpansionPanel( + isExpanded: _expandState[1], + headerBuilder: ((context, isExpanded) { + return const ListTile(title: Text("全局设置"), subtitle: Text("在这里关广告、开启终端编辑")); + }), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [ + TextFormField(maxLines: null, initialValue: G.prefs.getString("defaultAudioPort"), decoration: const InputDecoration(border: OutlineInputBorder(), labelText: "pulseaudio接收端口"), onChanged: (value) async { + await G.prefs.setString("defaultAudioPort", value); + }), + SizedBox.fromSize(size: const Size.square(8)), + SwitchListTile(title: const Text("关闭横幅广告"), value: G.prefs.getBool("isBannerAdsClosed")!, onChanged:(value) { + if (value && (G.prefs.getInt("adsWatchedTotal")! == 0)) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("观看一个视频广告解锁><")) + ); + return; + } + G.prefs.setBool("isBannerAdsClosed", value); + setState(() {}); + },), + SizedBox.fromSize(size: const Size.square(8)), + SwitchListTile(title: const Text("启用终端"), value: G.prefs.getBool("isTerminalWriteEnabled")!, onChanged:(value) { + if (value && (G.prefs.getInt("adsWatchedTotal")! == 0)) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: const Text("观看一个视频广告解锁><"), action: SnackBarAction(label: "啊?", onPressed: () { + G.prefs.setBool("isTerminalWriteEnabled", value); + setState(() {}); + },)) + ); + return; + } + G.prefs.setBool("isTerminalWriteEnabled", value); + setState(() {}); + },), + SizedBox.fromSize(size: const Size.square(8)), + SwitchListTile(title: const Text("开启时启动图形界面"), value: G.prefs.getBool("autoLaunchVnc")!, onChanged:(value) { + G.prefs.setBool("autoLaunchVnc", value); + setState(() {}); + },), + ],))), + ExpansionPanel( + isExpanded: _expandState[2], + headerBuilder: ((context, isExpanded) { + return const ListTile(title: Text("广告记录")); + }), body: Padding(padding: const EdgeInsets.all(12), child: Column(children: [ + OutlinedButton(child: const Text("看一个广告"), onPressed: () { + if (AdManager.placements[AdManager.rewardedVideoAdPlacementId]!) { + AdManager.showAd(AdManager.rewardedVideoAdPlacementId, () { + final bonus = Util.getRandomBonus(); + Util.applyBonus(bonus); + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("你获得了 ${bonus["name"]}*${bonus["amount"]}")) + ); + setState(() { + + }); + }, () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("已经看5个广告了, 今天也非常感谢><")) + ); + }); + } + }), + const SizedBox.square(dimension: 8), + Text(G.prefs.getStringList("adsBonus")!.map((element) { + final e = jsonDecode(element); + return e["amount"]==0?"":"${e["name"]}*${e["amount"]}\n"; + }).join()) + ],))), + ],), + SizedBox.fromSize(size: const Size.square(8)), + const InfoPage() + ] + ) + )) + ,) + ] + ))) + )][pageIndex], + transitionBuilder: (child, animation) { + return FadeTransition(opacity: animation, child: child); + },))]):const LoadingPage(), bottomNavigationBar: Visibility(visible: isLoadingComplete, child: BottomNavigationBar(currentIndex: pageIndex, onTap: (index) { diff --git a/lib/workflow.dart b/lib/workflow.dart index 0eca640..cd5da18 100644 --- a/lib/workflow.dart +++ b/lib/workflow.dart @@ -18,6 +18,9 @@ import 'dart:io'; import 'dart:async'; import 'dart:convert'; +import 'dart:math'; + +import 'package:intl/intl.dart'; import 'package:http/http.dart' as http; import 'package:retry/retry.dart'; @@ -35,10 +38,9 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:unity_ads_plugin/unity_ads_plugin.dart'; + class Util { - static bool isFirstTime() { - return (! Directory("${G.dataPath}/bin").existsSync()) || File("${G.dataPath}/xao").existsSync(); - } static Future copyAsset(String src, String dst) async { await File(dst).writeAsBytes((await rootBundle.load(src)).buffer.asUint8List()); @@ -60,58 +62,81 @@ class Util { } static void termWrite(String str) { - G.pty.write(const Utf8Encoder().convert("$str\n")); + G.termPtys[G.currentContainer]!.pty.write(const Utf8Encoder().convert("$str\n")); + } + + static dynamic getCurrentProp(String key) { + return jsonDecode(G.prefs.getStringList("containersInfo")![G.currentContainer])[key]; + } + + //用来设置name, boot, vnc, vncUrl等 + static Future setCurrentProp(String key, dynamic value) async { + await G.prefs.setStringList("containersInfo", + G.prefs.getStringList("containersInfo")!..setAll(G.currentContainer, + [jsonEncode((jsonDecode( + G.prefs.getStringList("containersInfo")![G.currentContainer] + ))..update(key, (v) => value))] + ) + ); + } + + //返回单个G.bonusTable定义的item + static Map getRandomBonus() { + final random = Random(); + final totalWeight = G.bonusTable.fold(0.0, (sum, item) => sum + item['weight']); + final randomIndex = random.nextDouble() * totalWeight; + var cumulativeWeight = 0.0; + for (final item in G.bonusTable) { + cumulativeWeight += item['weight']; + if (randomIndex <= cumulativeWeight) { + return item; + } + } + return G.bonusTable[0]; + } + + //由getRandomBonus返回的数据 + static Future applyBonus(Map bonus) async { + bool flag = false; + List ret = G.prefs.getStringList("adsBonus")!.map((e) { + Map item = jsonDecode(e); + return (item["name"] == bonus["name"])? + jsonEncode(item..update("amount", (v) { + flag = true; + return v + bonus["amount"]; + })):e; + }).toList(); + if (!flag) { + ret.add("""{"name": "${bonus["name"]}", "amount": ${bonus["amount"]}}"""); + } + await G.prefs.setStringList("adsBonus", ret); + print(G.prefs.getStringList("adsBonus")!); } } -// Global variables -class G { - static late final String dataPath; - static late Terminal terminal; - static late Pty pty; - static late WebViewController controller; - static late BuildContext homePageStateContext; +//一个结合terminal和pty的类 +class TermPty { + late final Terminal terminal; + late final Pty pty; - static late SharedPreferences prefs; - - - static const String vncUrl = "http://localhost:36080/vnc.html?host=localhost&port=36080&autoconnect=true&resize=remote"; -} - -class Workflow { - - static Future grantPermissions() async { - Permission.storage.request(); - Permission.manageExternalStorage.request(); - } - - static Future initData() async { - - G.prefs = await SharedPreferences.getInstance(); - - } - - static Future initTerminal() async { - - G.dataPath = (await getApplicationSupportDirectory()).path; - - G.controller = WebViewController()..setJavaScriptMode(JavaScriptMode.unrestricted); - - G.terminal = Terminal(); - - G.pty = Pty.start( + TermPty() { + terminal = Terminal(); + pty = Pty.start( "/system/bin/sh", workingDirectory: G.dataPath, - columns: G.terminal.viewWidth, - rows: G.terminal.viewHeight, + columns: terminal.viewWidth, + rows: terminal.viewHeight, ); - G.pty.output + pty.output .cast>() .transform(const Utf8Decoder()) - .listen(G.terminal.write); - G.pty.exitCode.then((code) { - G.terminal.write('the process exited with exit code $code'); - //TO_DO: Singal 9 hint + .listen(terminal.write); + pty.exitCode.then((code) { + terminal.write('the process exited with exit code $code'); + if (code == 0) { + SystemChannels.platform.invokeMethod("SystemNavigator.pop"); + } + //TODO: Singal 9 hint, 改成对话框 if (code == -9) { Navigator.push(G.homePageStateContext, MaterialPageRoute(builder: (context) { const TextStyle ts = TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.normal); @@ -131,87 +156,337 @@ class Workflow { })); } }); - G.terminal.onOutput = (data) { - G.pty.write(const Utf8Encoder().convert(data)); + terminal.onOutput = (data) { + if (G.prefs.getBool("isTerminalWriteEnabled")!) { + pty.write(const Utf8Encoder().convert(data)); + } }; - G.terminal.onResize = (w, h, pw, ph) { - G.pty.resize(h, w); + terminal.onResize = (w, h, pw, ph) { + pty.resize(h, w); }; - } +} + +// Global variables +class G { + static late final String dataPath; + static Pty? audioPty; + static late WebViewController controller; + static late BuildContext homePageStateContext; + static late int currentContainer; //目前运行第几个容器 + static late Map termPtys; //为容器存放TermPty数据 + static late AdManager ads;//广告实例 + + + //看广告可以获得的奖励。 + //weight抽奖权重,singleUse使用一次花费的数量,amount抽中可以获得的数量 + static const List> bonusTable = [ + {"name": "开发者的祝福", "subtitle": "支持开发者的证明", "description": "(*'v'*)\n开发者由衷地感谢你!", "weight": 10000, "amount": 1, "singleUse": 0}, + {"name": "记忆晶片", "subtitle": "看上去像平行四边形", "description": "组成记忆空间的基本元素。\n是从哪里掉下来的呢?", "weight": 50, "amount": 1, "singleUse": 0}, + {"name": "Wishes Flower Part", "subtitle": "为1个人献上祝福", "description": "希望之花的花瓣。在想好为谁祝福后, 点击使用", "weight": 500, "amount": 1, "singleUse": 1}, + {"name": "Wishes Flower Part", "subtitle": "为1个人献上祝福", "description": "希望之花的花瓣。在想好为谁祝福后, 点击使用", "weight": 100, "amount": 3, "singleUse": 1}, + {"name": "Wishes Flower", "subtitle": "为3个人献上祝福", "description": "希望之花。在想好为谁祝福后, 点击使用", "weight": 50, "amount": 1, "singleUse": 1}, + {"name": "Bonus Reward", "subtitle": "会有极好的事情发生", "description": "来自记忆空间的传说。\n使用后一天内必有极好的事情...\n就是你想象的那种事情...\n就会发生。\n不过, 大概只是个传说吧。", "weight": 10, "amount": 0.01, "singleUse": 1}, + {"name": "Bonus Reward", "subtitle": "会有极好的事情发生", "description": "来自记忆空间的传说。\n使用后一天内必有极好的事情...\n就是你想象的那种事情...\n就会发生。\n不过, 大概只是个传说吧。", "weight": 1, "amount": 0.1, "singleUse": 1}, + {"name": "Bonus Reward", "subtitle": "会有极好的事情发生", "description": "来自记忆空间的传说。\n使用后一天内必有极好的事情...\n就是你想象的那种事情...\n就会发生。\n不过, 大概只是个传说吧。", "weight": 1, "amount": 1, "singleUse": 1}, + ]; + + //所有key + //int defaultContainer = 0: 默认启动第0个容器 + //String defaultAudioPort = 4713: 默认pulseaudio端口(为了避免和其它软件冲突改成4718了) !!!注意!这个值是String类型 + //bool autoLaunchVnc = true: 是否自动启动VNC并跳转 + //String lastDate: 上次启动软件的日期,yyyy-MM-dd + //int adsWatchedToday: 今日视频广告观看数量 + //int adsWatchedTotal: 视频广告观看数量 + //bool isBannerAdsClosed = false + //bool bannerAdsCanBeClosed = false 看一次视频广告永久开启,历史遗留 + //bool isTerminalWriteEnabled = false + //bool terminalWriteCanBeEnabled = false 看一次视频广告永久开启,历史遗留 + //? int bootstrapVersion: 启动包版本 + //String[] containersInfo: 所有容器信息(json) + //{name, boot:"\$DATA_DIR/bin/proot ...", vnc:"startnovnc", vncUrl:"...", commands:[{name:"更新和升级", command:"apt update -y && apt upgrade -y"}, ...]} + //String[] adsBonus: 观看广告获取的奖励(json) + //{name: "xxx", amount: xxx} + static late SharedPreferences prefs; + +} + +class AdManager { + static Map placements = { + interstitialVideoAdPlacementId: false, + rewardedVideoAdPlacementId: false, + }; + + static void loadAds() { + for (var placementId in placements.keys) { + loadAd(placementId); + } + } + + static void loadAd(String placementId) { + UnityAds.load( + placementId: placementId, + onComplete: (placementId) { + print('Load Complete $placementId'); + placements[placementId] = true; + }, + onFailed: (placementId, error, message) => print('Load Failed $placementId: $error $message'), + ); + } + + static void showAd(String placementId, Function completeExtra, Function full) { + + if (G.prefs.getInt("adsWatchedToday")!>=5) { + full(); + return; + } + + placements[placementId] = false; + UnityAds.showVideoAd( + placementId: placementId, + onComplete: (placementId) async { + print('Video Ad $placementId completed'); + loadAd(placementId); + await G.prefs.setInt("adsWatchedTotal", G.prefs.getInt("adsWatchedTotal")!+1); + await G.prefs.setInt("adsWatchedToday", G.prefs.getInt("adsWatchedToday")!+1); + completeExtra(); + }, + onFailed: (placementId, error, message) { + print('Video Ad $placementId failed: $error $message'); + loadAd(placementId); + }, + onStart: (placementId) => print('Video Ad $placementId started'), + onClick: (placementId) => print('Video Ad $placementId click'), + onSkipped: (placementId) { + print('Video Ad $placementId skipped'); + loadAd(placementId); + }, + ); + } + + static String get gameId { + if (defaultTargetPlatform == TargetPlatform.android) { + return '5403132'; + } + if (defaultTargetPlatform == TargetPlatform.iOS) { + return '5403133'; + } + return ''; + } + + static String bannerAdPlacementId = 'Banner_Android'; + + static String interstitialVideoAdPlacementId = 'Interstitial_Android'; + + static String rewardedVideoAdPlacementId = 'Rewarded_Android'; +} + +class Workflow { + + static Future grantPermissions() async { + Permission.storage.request(); + Permission.manageExternalStorage.request(); + } + static Future setupBootstrap() async { + //用来共享数据文件的文件夹 Util.createDirFromString("${G.dataPath}/share"); - Util.createDirFromString("${G.dataPath}/debian"); + //挂载到/dev/shm的文件夹 Util.createDirFromString("${G.dataPath}/tmp"); + //给proot的tmp文件夹,虽然我不知道为什么proot要这个 + Util.createDirFromString("${G.dataPath}/proot_tmp"); + //解压后得到bin文件夹和libexec文件夹 + //bin存放了proot和pulseaudio + //libexec存放了proot loader await Util.copyAsset( "assets/assets.zip", "${G.dataPath}/assets.zip", ); - for (String name in ["xaa", "xab", "xac", "xad", "xae", "xaf", "xag", "xah", "xai", "xaj", "xak", "xal", "xam", "xan", "xao"]) { - //for (String name in ["xaa"]) { - await Util.copyAsset("assets/$name", "${G.dataPath}/$name"); - } + //dddd await Util.copyAsset( "assets/busybox", "${G.dataPath}/busybox", ); await Util.execute( """ -cd ${G.dataPath} +export DATA_DIR=${G.dataPath} +cd \$DATA_DIR chmod +x busybox -${G.dataPath}/busybox unzip assets.zip +\$DATA_DIR/busybox unzip assets.zip chmod -R +x bin/* chmod -R +x libexec/proot/* -cat xa* | ${G.dataPath}/busybox tar x -J -v -C debian -${G.dataPath}/busybox rm -rf assets.zip xa* +chmod 1777 tmp +ln -s \$DATA_DIR/busybox \$DATA_DIR/bin/xz +\$DATA_DIR/busybox rm -rf assets.zip """); } - static Future launchDefaultContainer() async { + //初次启动要做的事情 + static Future initForFirstTime() async { + //首先设置bootstrap + await setupBootstrap(); + //存放容器的文件夹0和存放硬链接的文件夹.l2s + Util.createDirFromString("${G.dataPath}/containers/0/.l2s"); + //这个是容器rootfs,被split命令分成了xa* + //首次启动,就用这个,别让用户另选了 + //TODO: 这个字符串列表太丑陋了 + //for (String name in ["xaa", "xab", "xac", "xad", "xae", "xaf", "xag", "xah", "xai", "xaj", "xak", "xal", "xam", "xan"]) { + for (String name in ["xaa", "xab", "xac", "xad", "xae", "xaf", "xag", "xah", "xai", "xaj", "xak", "xal", "xam"]) { + await Util.copyAsset("assets/$name", "${G.dataPath}/$name"); + } + //-J + await Util.execute( +""" +export DATA_DIR=${G.dataPath} +export CONTAINER_DIR=\$DATA_DIR/containers/0 +cd \$DATA_DIR +export PATH=\$DATA_DIR/bin:\$PATH +export PROOT_TMP_DIR=\$DATA_DIR/proot_tmp +export PROOT_LOADER=\$DATA_DIR/libexec/proot/loader +export PROOT_LOADER_32=\$DATA_DIR/libexec/proot/loader32 +export PROOT_L2S_DIR=\$CONTAINER_DIR/.l2s +\$DATA_DIR/bin/proot --link2symlink -H sh -c "cat xa* | \$DATA_DIR/bin/tar x -J --delay-directory-restore --preserve-permissions -v -C containers/0" +#Script from proot-distro +chmod u+rw "\$CONTAINER_DIR/etc/passwd" "\$CONTAINER_DIR/etc/shadow" "\$CONTAINER_DIR/etc/group" "\$CONTAINER_DIR/etc/gshadow" +echo "aid_\$(id -un):x:\$(id -u):\$(id -g):Termux:/:/sbin/nologin" >> "\$CONTAINER_DIR/etc/passwd" +echo "aid_\$(id -un):*:18446:0:99999:7:::" >> "\$CONTAINER_DIR/etc/shadow" +id -Gn | tr ' ' '\\n' > tmp1 +id -G | tr ' ' '\\n' > tmp2 +\$DATA_DIR/busybox paste tmp1 tmp2 > tmp3 +local group_name group_id +cat tmp3 | while read -r group_name group_id; do + echo "aid_\${group_name}:x:\${group_id}:root,aid_\$(id -un)" >> "\$CONTAINER_DIR/etc/group" + if [ -f "\$CONTAINER_DIR/etc/gshadow" ]; then + echo "aid_\${group_name}:*::root,aid_\$(id -un)" >> "\$CONTAINER_DIR/etc/gshadow" + fi +done +\$DATA_DIR/busybox rm -rf xa* tmp1 tmp2 tmp3 +"""); + //一些数据初始化 + //$DATA_DIR是数据文件夹, $CONTAINER_DIR是容器根目录 + //容器根目录会有一个fake-proc文件夹存放一些假的proc文件供挂载 + //"boot":"\$DATA_DIR/bin/proot --link2symlink -H --kill-on-exit --tcsetsf2tcsetsw --root-id --pwd=/root --rootfs=\$CONTAINER_DIR -L --kernel-release=6.2.1-PRoot-Distro --bind=\$DATA_DIR/tmp:/dev/shm --bind=/sys --bind=/proc/self/fd/2:/dev/stderr --bind=/proc/self/fd/1:/dev/stdout --bind=/proc/self/fd/0:/dev/stdin --bind=/proc/self/fd:/dev/fd --bind=/proc --bind=/dev/urandom:/dev/random --bind=/dev --bind=\$CONTAINER_DIR/fake-proc/.loadavg:/proc/loadavg --bind=\$CONTAINER_DIR/fake-proc/.stat:/proc/stat --bind=\$CONTAINER_DIR/fake-proc/.uptime:/proc/uptime --bind=\$CONTAINER_DIR/fake-proc/.version:/proc/version --bind=\$CONTAINER_DIR/fake-proc/.vmstat:/proc/vmstat --bind=\$CONTAINER_DIR/fake-proc/.sysctl_entry_cap_last_cap:/proc/sys/kernel/cap_last_cap /usr/bin/env -i HOME=/root USER=root TERM=xterm-256color SDL_IM_MODULE=fcitx XMODIFIERS=\\\\@im=fcitx QT_IM_MODULE=fcitx GTK_IM_MODULE=fcitx TMPDIR=/tmp DISPLAY=:4 PULSE_SERVER=tcp:127.0.0.1:4718 LANG=zh_CN.UTF-8 SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/bin:/usr/bin:/sbin:/usr/sbin:/usr/games:/usr/local/games /bin/bash -l", + await G.prefs.setStringList("containersInfo", ["""{ +"name":"Ubuntu Jammy", +"boot":"\$DATA_DIR/bin/proot --link2symlink --ashmem-memfd --sysvipc -H --kill-on-exit --root-id --pwd=/root --rootfs=\$CONTAINER_DIR -L --kernel-release=6.2.1-PRoot-Distro --mount=\$DATA_DIR/share:/media/share --mount=/storage/self/primary/Fonts:/usr/share/fonts/wpsm --mount=/storage/self/primary/AppFiles/Fonts:/usr/share/fonts/yozom --mount=/system/fonts:/usr/share/fonts/androidm --mount=/storage/self/primary:/media/storage/shared --mount=/storage/self/primary/Pictures:/media/storage/Pictures --mount=/storage/self/primary/Music:/media/storage/Music --mount=/storage/self/primary/Movies:/media/storage/Movies --mount=/storage/self/primary/Download:/media/storage/Download --mount=/storage/self/primary/DCIM:/media/storage/DCIM --mount=/storage/self/primary/Documents:/media/storage/Documents --bind=\$DATA_DIR/tmp:/dev/shm --bind=/sys --bind=/proc/self/fd/2:/dev/stderr --bind=/proc/self/fd/1:/dev/stdout --bind=/proc/self/fd/0:/dev/stdin --bind=/proc/self/fd:/dev/fd --bind=/proc --bind=/dev/urandom:/dev/random --bind=/dev --bind=\$CONTAINER_DIR/fake-proc/.loadavg:/proc/loadavg --bind=\$CONTAINER_DIR/fake-proc/.stat:/proc/stat --bind=\$CONTAINER_DIR/fake-proc/.uptime:/proc/uptime --bind=\$CONTAINER_DIR/fake-proc/.version:/proc/version --bind=\$CONTAINER_DIR/fake-proc/.vmstat:/proc/vmstat --bind=\$CONTAINER_DIR/fake-proc/.sysctl_entry_cap_last_cap:/proc/sys/kernel/cap_last_cap /usr/bin/env -i HOME=/root USER=root TERM=xterm-256color SDL_IM_MODULE=fcitx XMODIFIERS=\\\\@im=fcitx QT_IM_MODULE=fcitx GTK_IM_MODULE=fcitx TMPDIR=/tmp DISPLAY=:4 PULSE_SERVER=tcp:127.0.0.1:4718 LANG=zh_CN.UTF-8 SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/bin:/usr/bin:/sbin:/usr/sbin:/usr/games:/usr/local/games /bin/bash -l", +"vnc":"tigervncserver :4 -SecurityTypes none && .local/share/noVNC/utils/novnc_proxy --vnc localhost:5904 --listen localhost:36082 &", +"vncUrl":"http://localhost:36082/vnc.html?host=localhost&port=36082&autoconnect=true&resize=remote", +"commands":[{"name":"检查更新并升级", "command":"apt update && apt upgrade -y"}, +{"name":"查看系统信息", "command":"uname -a"}, +{"name":"清屏", "command":"clear"}, +{"name":"安装图形处理软件Krita", "command":"apt update && apt install -y krita krita-l10n"}, +{"name":"卸载图形处理软件Krita", "command":"apt autoremove --purge -y krita krita-l10n"}, +{"name":"安装视频剪辑软件Kdenlive", "command":"apt update && apt install -y kdenlive"}, +{"name":"卸载视频剪辑软件Kdenlive", "command":"apt autoremove --purge -y kdenlive"}, +{"name":"安装科学计算软件Octave", "command":"apt update && apt install -y octave"}, +{"name":"卸载科学计算软件Octave", "command":"apt autoremove --purge -y octave"}, +{"name":"???", "command":"timeout 8 /root/.local/bin/cmatrix"}] +}"""]); + await G.prefs.setStringList("adsBonus", []); + await G.prefs.setInt("adsWatchedTotal", 0); + //await G.prefs.setBool("terminalWriteCanBeEnabled", false); + //G.prefs.setBool("isTerminalWriteEnabled", false); + await G.prefs.setBool("isTerminalWriteEnabled", false); + //await G.prefs.setBool("bannerAdsCanBeClosed", false); + await G.prefs.setBool("isBannerAdsClosed", false); + //G.prefs.setBool("autoLaunchVnc", true); + await G.prefs.setBool("autoLaunchVnc", true); + await G.prefs.setString("defaultAudioPort", "4718"); + await G.prefs.setInt("defaultContainer", 0); + } + + static Future initData() async { + + G.dataPath = (await getApplicationSupportDirectory()).path; + + G.termPtys = {}; + + G.prefs = await SharedPreferences.getInstance(); + + //限制一天内观看视频广告不超过5次 + final String currentDate = DateFormat("yyyy-MM-dd").format(DateTime.now()); + if (currentDate != G.prefs.getString("lastDate")) { + await G.prefs.setString("lastDate", currentDate); + await G.prefs.setInt("adsWatchedToday", 0); + } + + //如果没有这个key,说明是初次启动 + if (!G.prefs.containsKey("defaultContainer")) { + await initForFirstTime(); + } + G.currentContainer = G.prefs.getInt("defaultContainer")!; + + G.controller = WebViewController()..setJavaScriptMode(JavaScriptMode.unrestricted); + + } + + static Future initTerminalForCurrent() async { + if (!G.termPtys.containsKey(G.currentContainer)) { + G.termPtys[G.currentContainer] = TermPty(); + } + } + + static Future initAds() async { + UnityAds.init( + gameId: AdManager.gameId, + testMode: true, + onComplete: () { + print('Initialization Complete'); + AdManager.loadAds(); + }, + onFailed: (error, message) => print('Initialization Failed: $error $message'), + ); + } + + static Future setupAudio() async { + G.audioPty?.kill(); + G.audioPty = Pty.start( + "/system/bin/sh" + ); + //pulseaudio也需要一个tmp文件夹,这里选择前面的cache,没有什么特别的原因,不行再换 + //pulseaudio还需要一个文件夹放配置,这里用share + G.audioPty!.write(const Utf8Encoder().convert(""" +export DATA_DIR=${G.dataPath} +cd \$DATA_DIR/.. +export TMPDIR=\$PWD/cache +cd \$DATA_DIR +export HOME=\$DATA_DIR/share +export LD_LIBRARY_PATH=\$DATA_DIR/bin +\$DATA_DIR/busybox sed "s/4713/${G.prefs.getString("defaultAudioPort")!}/g" \$DATA_DIR/bin/pulseaudio.conf > \$DATA_DIR/bin/pulseaudio.conf.tmp +\$DATA_DIR/bin/pulseaudio -F \$DATA_DIR/bin/pulseaudio.conf.tmp +exit +""")); + await G.audioPty?.exitCode; + } + + static Future launchCurrentContainer() async { Util.termWrite( """ -cd ${G.dataPath}/.. -export TMPDIR=\$PWD/cache -cd ${G.dataPath} -export HOME=\$PWD/share -export LD_LIBRARY_PATH=\$PWD/bin -\$PWD/bin/pulseaudio -F \$PWD/bin/pulseaudio.conf >/dev/null 2>&1 & -export PROOT_TMP_DIR=\$PWD/tmp -export PROOT_LOADER=\$PWD/libexec/proot/loader -export PROOT_LOADER_32=\$PWD/libexec/proot/loader32 -${G.dataPath}/bin/proot --mute-setxid --tcsetsf2tcsetsw --root-id --pwd=/root --rootfs=${G.dataPath}/debian --mount=/system --mount=/apex --kill-on-exit --mount=/storage:/storage --mount=${G.dataPath}/share:/media/share -L --link2symlink --mount=/proc:/proc --mount=/dev:/dev --mount=${G.dataPath}/debian/tmp:/dev/shm --mount=/dev/urandom:/dev/random --mount=/proc/self/fd:/dev/fd --mount=/proc/self/fd/0:/dev/stdin --mount=/proc/self/fd/1:/dev/stdout --mount=/proc/self/fd/2:/dev/stderr --mount=/dev/null:/dev/tty0 --mount=/dev/null:/proc/sys/kernel/cap_last_cap --mount=/storage/self/primary/Fonts:/usr/share/fonts/wpsm --mount=/storage/self/primary/AppFiles/Fonts:/usr/share/fonts/yozom --mount=/storage/self/primary:/media/storage/shared --mount=/storage/self/primary/Pictures:/media/storage/Pictures --mount=/storage/self/primary/Music:/media/storage/Music --mount=/storage/self/primary/Movies:/media/storage/Movies --mount=/storage/self/primary/Download:/media/storage/Download --mount=/storage/self/primary/DCIM:/media/storage/DCIM --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/.tmoe-container.stat:/proc/stat --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/.tmoe-container.version:/proc/version --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/bus:/proc/bus --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/buddyinfo:/proc/buddyinfo --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/cgroups:/proc/cgroups --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/consoles:/proc/consoles --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/crypto:/proc/crypto --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/devices:/proc/devices --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/diskstats:/proc/diskstats --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/execdomains:/proc/execdomains --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/fb:/proc/fb --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/filesystems:/proc/filesystems --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/interrupts:/proc/interrupts --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/iomem:/proc/iomem --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/ioports:/proc/ioports --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/kallsyms:/proc/kallsyms --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/keys:/proc/keys --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/key-users:/proc/key-users --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/kpageflags:/proc/kpageflags --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/loadavg:/proc/loadavg --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/locks:/proc/locks --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/misc:/proc/misc --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/modules:/proc/modules --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/pagetypeinfo:/proc/pagetypeinfo --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/partitions:/proc/partitions --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/sched_debug:/proc/sched_debug --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/softirqs:/proc/softirqs --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/timer_list:/proc/timer_list --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/uptime:/proc/uptime --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/vmallocinfo:/proc/vmallocinfo --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/vmstat:/proc/vmstat --mount=${G.dataPath}/debian/usr/local/etc/tmoe-linux/proot_proc/zoneinfo:/proc/zoneinfo /usr/bin/env -i HOSTNAME=TINY HOME=/root USER=root TERM=xterm-256color SDL_IM_MODULE=fcitx XMODIFIERS=\\@im=fcitx QT_IM_MODULE=fcitx GTK_IM_MODULE=fcitx TMOE_CHROOT=false TMOE_PROOT=true TMPDIR=/tmp DISPLAY=:2 PULSE_SERVER=tcp:127.0.0.1:4713 LANG=zh_CN.UTF-8 SHELL=/bin/zsh PATH=/usr/local/sbin:/usr/local/bin:/bin:/usr/bin:/sbin:/usr/sbin:/usr/games:/usr/local/games /bin/zsh -l -startnovnc"""); +export DATA_DIR=${G.dataPath} +export CONTAINER_DIR=\$DATA_DIR/containers/${G.currentContainer} +export PROOT_L2S_DIR=\$DATA_DIR/containers/0/.l2s +cd \$DATA_DIR +export PROOT_TMP_DIR=\$DATA_DIR/proot_tmp +export PROOT_LOADER=\$DATA_DIR/libexec/proot/loader +export PROOT_LOADER_32=\$DATA_DIR/libexec/proot/loader32 +${Util.getCurrentProp("boot")} +${G.prefs.getBool("autoLaunchVnc")!?Util.getCurrentProp("vnc"):""} +clear"""); } static Future waitForConnection() async { - // Future testConnection(String url) async { - // try { - // return (await http.get(Uri.parse(url))).statusCode == 200; - // } catch (e) { - // return false; - // } - // } - // for (;;) { - // await Future.delayed(const Duration(milliseconds: 1000), () async { - // print("meow"); - // if (await testConnection(G.vncUrl)) { - // return; - // } - // } - // ); - // } await retry( // Make a GET request - () => http.get(Uri.parse(G.vncUrl)).timeout(const Duration(milliseconds: 250)), + () => http.get(Uri.parse(Util.getCurrentProp("vncUrl"))).timeout(const Duration(milliseconds: 250)), // Retry on SocketException or TimeoutException retryIf: (e) => e is SocketException || e is TimeoutException, ); } static Future launchBrowser() async { - G.controller.loadRequest(Uri.parse(G.vncUrl)); + G.controller.loadRequest(Uri.parse(Util.getCurrentProp("vncUrl"))); Navigator.push(G.homePageStateContext, MaterialPageRoute(builder: (context) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky,overlays: []); SystemChrome.setSystemUIChangeCallback((systemOverlaysAreVisible) async { @@ -244,12 +519,13 @@ startnovnc"""); static Future workflow() async { grantPermissions(); await initData(); - await initTerminal(); - if (Util.isFirstTime()) { - await setupBootstrap(); + await initAds(); + await initTerminalForCurrent(); + setupAudio(); + launchCurrentContainer(); + if (G.prefs.getBool("autoLaunchVnc")!) { + waitForConnection().then((value) => launchBrowser()); } - launchDefaultContainer(); - waitForConnection().then((value) => launchBrowser()); } } diff --git a/pubspec.lock b/pubspec.lock index ef4202c..845044e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -128,6 +128,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" lints: dependency: transitive description: @@ -413,6 +421,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + unity_ads_plugin: + dependency: "direct main" + description: + name: unity_ads_plugin + sha256: "2b19ff02089e5a0d1a6549a2cd9d2baf5f8733bdda296d947d411a293f0bffda" + url: "https://pub.dev" + source: hosted + version: "0.3.8" url_launcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9835618..1816675 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.2+1 +version: 1.0.4+1 environment: sdk: '>=3.1.0 <4.0.0' @@ -39,6 +39,8 @@ dependencies: retry: ^3.1.2 url_launcher: ^6.1.12 shared_preferences: ^2.2.1 + intl: ^0.18.1 + unity_ads_plugin: ^0.3.8 # The following adds the Cupertino Icons font to your application. diff --git a/readme/README-1.0.1.md b/readme/README-1.0.1.md new file mode 100644 index 0000000..18ff0a6 --- /dev/null +++ b/readme/README-1.0.1.md @@ -0,0 +1,85 @@ +这个文件夹存放过时的readme + +# 小小电脑 + + + +点开软件就是电脑 + +Click-to-run debian 12 xfce on android for Chinese users, with fcitx pinyin input method and wps office preinstalled. No termux required. + +## 原理 + +使用proot运行debian环境 + +内置[noVNC](https://github.com/novnc/noVNC)显示图形界面 + +初次启动由于解压的缘故要点时间 +以后点开就能用 + +只支持arm64安卓 + +**目前新安装的软件无法读写文件,但可以访问手机存储,原因未知** + +(我接下来可能会排查一下是proot还是容器的问题 +顺便学习一下容器是怎么做的 +毕竟我的修改可能出了问题) + +## 项目结构 + +assets的文件来源如下: + +- [build-proot-android, proot二进制文件](https://github.com/green-green-avk/build-proot-android) +- [busybox](https://github.com/meefik/busybox) +- [Xserver XSDL, pulseaudio相关文件](https://github.com/pelya/commandergenius/tree/sdl_android/project/jni/application/xserver) +- [Tmoe Linux, debian包来源](https://github.com/2moe/tmoe) + +其中proot、busybox和pulseaudio相关文件都是直接用了二进制文件。 + +(pulseaudio我真的编译不来,如果你会的话请教教我吧) + +对debian容器进行了如下修改: +- 使用tmoe工具安装了xfce环境和全套VNC; +- 安装了wps office, 对wps office进行了如下修改: + - 界面改成了多组件,避免无法打开wps; + - 根据[这篇文章](https://forums.debiancn.org/t/topic/4015/8)创建了libtiff软链,避免无法打开wpspdf + - 补上了缺失的字体; +- 安装了VS Code和中文插件; +- 安装了fcitx输入法和云拼音组件。按切换输入法。 + - 强烈建议**不要**使用安卓中文输入法直接输入中文,而是使用英文键盘通过容器的输入法输入中文,避免丢字错字。 +- 对VNC启动脚本进行修改,删除了tigerVNC密码验证; + - 虽然不太可能,但如果还是被问到密码的话输12345678 +- 对noVNC脚本(/usr/local/etc/tmoe-linux/novnc/core/rfb.js)进行修改,添加了userScale变量控制缩放 + - 默认显示太大了,很多窗口点开都超出了屏幕范围,目前我使显示缩小了userScale=1.5倍 +- 改掉了一些容器里的Termux硬链接,有一些.git文件夹里的没改,应该无伤大雅吧=v= +- 最后采用tar.xz压缩,用split命令分成了xa*等多个文件 + +lib目录: + +- main.dart文件,页面布局,目前只有一个页面,非常简单 +- workflow.dart文件,逻辑部分,目前也还算简单 + - Util 工具类 + - G 全局变量类 + - Workflow 从软件点开到容器启动的所有步骤 + +## 一些链接 + +这是我的第一个flutter软件,感谢这些项目为我指路 + +- 要一点基础的 [《Flutter实战·第二版》](https://book.flutterchina.club) +- 也许是零基础的Flutter视频课程 [freeCodeCamp Flutter Course](https://www.youtube.com/watch?v=wFn-m-OgKPU&list=PL6yRaaP0WPkVtoeNIGqILtRAgd3h2CNpT) + +- 安卓上的VS Code [Code FA](https://github.com/nightmare-space/vscode_for_android) + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/assets/cover0.png b/readme/cover0.png similarity index 100% rename from assets/cover0.png rename to readme/cover0.png