mirror of
https://github.com/Cateners/tiny_computer.git
synced 2026-05-20 16:35:47 +08:00
Initial commit
This commit is contained in:
239
lib/main.dart
Normal file
239
lib/main.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
//import 'package:xterm/flutter.dart';
|
||||
import 'package:tiny_computer/workflow.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Tiny Computer',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
//fontFamily: "FiraCode",
|
||||
),
|
||||
home: const MyHomePage(title: 'Tiny Computer'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FakeLoadingStatus extends StatefulWidget {
|
||||
const FakeLoadingStatus({super.key});
|
||||
|
||||
@override
|
||||
State<FakeLoadingStatus> createState() => _FakeLoadingStatusState();
|
||||
}
|
||||
|
||||
class _FakeLoadingStatusState extends State<FakeLoadingStatus> {
|
||||
|
||||
double _progressT = 0;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
|
||||
setState(() {
|
||||
_progressT += 0.1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LinearProgressIndicator(value: 1 - pow(10, _progressT / -300).toDouble());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class InfoPage extends StatefulWidget {
|
||||
const InfoPage({super.key});
|
||||
|
||||
@override
|
||||
State<InfoPage> createState() => _InfoPageState();
|
||||
}
|
||||
|
||||
class _InfoPageState extends State<InfoPage> {
|
||||
final List<bool> _expandState = [true, false, false, false];
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpansionPanelList(
|
||||
elevation: 1,
|
||||
expandedHeaderPadding: const EdgeInsets.all(0),
|
||||
expansionCallback: (panelIndex, isExpanded) {
|
||||
setState(() {
|
||||
_expandState[panelIndex] = isExpanded;
|
||||
});
|
||||
},
|
||||
children: [
|
||||
ExpansionPanel(
|
||||
headerBuilder: (context, isExpanded) {
|
||||
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
|
||||
第一次加载,大概需要几分钟...
|
||||
请不要在安装时退出软件
|
||||
你可以在等待的同时阅读“隐私政策”和“服务条款”,
|
||||
阅读完后可以关闭广告
|
||||
...不过我还没写(广告也没放)
|
||||
|
||||
一些注意事项:
|
||||
此软件免费开源
|
||||
项目地址:
|
||||
如果是买的就是被骗了, 请举报!
|
||||
((然后请我喝水!!!!!!)(不是))
|
||||
|
||||
如果遇到android 12的signal 9问题
|
||||
请自行查找教程修复
|
||||
并不难
|
||||
此软件因为没有权限
|
||||
所以不能帮你修复
|
||||
一般只要你以前修复过(Tmoe脚本、Vmos助手、全手动adb等等)
|
||||
现在就不用再次修复
|
||||
|
||||
这个项目没有使用Termux
|
||||
因为我不太喜欢Termux的路径硬编码
|
||||
路径硬编码会导致软件在多用户/分身等场景无法使用
|
||||
|
||||
当然这样一来就用不了Termux的软件生态了
|
||||
比如我不会编译pulseaudio
|
||||
现在软件就没有声音
|
||||
|
||||
项目采用proot运行tmoe的debian12(xfce)
|
||||
debian系统里预装了WPS, VSCode和fcitx输入法
|
||||
界面是webview+noVNC
|
||||
|
||||
如果你给了存储权限
|
||||
那么可以从storage目录访问手机目录
|
||||
所以任何时候都不要尝试rm -rf /*
|
||||
|
||||
(顺带一提, 全部解压完大概需要7GB空间
|
||||
解压途中占用空间可能达到9GB
|
||||
请确保有足够的空间
|
||||
(这样真的Tiny吗><))
|
||||
|
||||
"""
|
||||
)),
|
||||
isExpanded: _expandState[0],
|
||||
),
|
||||
ExpansionPanel(
|
||||
isExpanded: _expandState[1],
|
||||
headerBuilder: ((context, isExpanded) {
|
||||
return const ListTile(title: Text("隐私政策"));
|
||||
}), body: const Padding(padding: EdgeInsets.all(8), child: Text("不知道怎么写"))),
|
||||
ExpansionPanel(
|
||||
isExpanded: _expandState[2],
|
||||
headerBuilder: ((context, isExpanded) {
|
||||
return const ListTile(title: Text("服务条款"));
|
||||
}), body: const Padding(padding: EdgeInsets.all(8), child: Text("要写什么"))),
|
||||
ExpansionPanel(
|
||||
isExpanded: _expandState[3],
|
||||
headerBuilder: ((context, isExpanded) {
|
||||
return const ListTile(title: Text("支持作者"));
|
||||
}), body: Column(
|
||||
children: [
|
||||
const Padding(padding: EdgeInsets.all(8), child: Text("请我喝一杯水吧")),
|
||||
const FractionallySizedBox(
|
||||
widthFactor: 0.8,
|
||||
child: Image(image: AssetImage("images/alipay.png"))
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
launchUrl(Uri.parse('https://flutter.dev'));
|
||||
},
|
||||
child: const Text("项目开源地址"),
|
||||
),
|
||||
]
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
G.homePageStateContext = context;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: Workflow.workflow(),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
return TerminalView(G.terminal);
|
||||
} else {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 0.4,
|
||||
child: Image(
|
||||
image: AssetImage("images/icon.png")
|
||||
)
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 0, 0, 8),
|
||||
child: Text("小小电脑", textScaleFactor: 2),
|
||||
),
|
||||
FakeLoadingStatus(),
|
||||
Expanded(child:
|
||||
Padding(padding: EdgeInsets.all(8), child: Card(child: Padding(padding: EdgeInsets.all(8), child:
|
||||
|
||||
Scrollbar(child:
|
||||
SingleChildScrollView(
|
||||
child: InfoPage()
|
||||
)
|
||||
)
|
||||
))
|
||||
,))
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => Workflow.launchBrowser(),
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.play_arrow),
|
||||
), // This trailing comma makes auto-formatting nicer for build methods.
|
||||
);
|
||||
}
|
||||
}
|
||||
202
lib/workflow.dart
Normal file
202
lib/workflow.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:retry/retry.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import 'package:xterm/xterm.dart';
|
||||
import 'package:flutter_pty/flutter_pty.dart';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class Util {
|
||||
static bool isFirstTime() {
|
||||
return (! Directory("${G.dataPath}/bin").existsSync()) || File("${G.dataPath}/xac").existsSync();
|
||||
}
|
||||
|
||||
static Future<void> copyAsset(String src, String dst) async {
|
||||
await File(dst).writeAsBytes((await rootBundle.load(src)).buffer.asUint8List());
|
||||
}
|
||||
static Future<void> copyAsset2(String src, String dst) async {
|
||||
ByteData data = await rootBundle.load(src);
|
||||
await File(dst).writeAsBytes(data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes));
|
||||
}
|
||||
static void createDirFromString(String dir) {
|
||||
Directory.fromRawPath(const Utf8Encoder().convert(dir)).createSync(recursive: true);
|
||||
}
|
||||
|
||||
static Future<void> execute(String str) async {
|
||||
Pty pty = Pty.start(
|
||||
"/system/bin/sh"
|
||||
);
|
||||
pty.write(const Utf8Encoder().convert("$str\nexit\n"));
|
||||
await pty.exitCode;
|
||||
}
|
||||
|
||||
static void termWrite(String str) {
|
||||
G.pty.write(const Utf8Encoder().convert("$str\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
static const String vncUrl = "http://localhost:36080/vnc.html?host=localhost&port=36080&autoconnect=true&resize=remote";
|
||||
}
|
||||
|
||||
class Workflow {
|
||||
|
||||
static Future<void> grantPermissions() async {
|
||||
Permission.storage.request();
|
||||
Permission.manageExternalStorage.request();
|
||||
}
|
||||
|
||||
static Future<void> initTerminal() async {
|
||||
|
||||
G.dataPath = (await getApplicationSupportDirectory()).path;
|
||||
|
||||
G.controller = WebViewController()..setJavaScriptMode(JavaScriptMode.unrestricted);
|
||||
|
||||
G.terminal = Terminal();
|
||||
|
||||
G.pty = Pty.start(
|
||||
"/system/bin/sh",
|
||||
workingDirectory: G.dataPath,
|
||||
columns: G.terminal.viewWidth,
|
||||
rows: G.terminal.viewHeight,
|
||||
);
|
||||
G.pty.output
|
||||
.cast<List<int>>()
|
||||
.transform(const Utf8Decoder())
|
||||
.listen(G.terminal.write);
|
||||
G.pty.exitCode.then((code) {
|
||||
G.terminal.write('the process exited with exit code $code');
|
||||
});
|
||||
G.terminal.onOutput = (data) {
|
||||
G.pty.write(const Utf8Encoder().convert(data));
|
||||
};
|
||||
G.terminal.onResize = (w, h, pw, ph) {
|
||||
G.pty.resize(h, w);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
static Future<void> setupBootstrap() async {
|
||||
Util.createDirFromString("${G.dataPath}/share");
|
||||
Util.createDirFromString("${G.dataPath}/debian");
|
||||
Util.createDirFromString("${G.dataPath}/tmp");
|
||||
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"]) {
|
||||
await Util.copyAsset("assets/$name", "${G.dataPath}/$name");
|
||||
}
|
||||
await Util.copyAsset(
|
||||
"assets/busybox",
|
||||
"${G.dataPath}/busybox",
|
||||
);
|
||||
await Util.execute(
|
||||
"""
|
||||
cd ${G.dataPath}
|
||||
chmod +x busybox
|
||||
${G.dataPath}/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*
|
||||
""");
|
||||
}
|
||||
|
||||
static Future<void> launchDefaultContainer() async {
|
||||
Util.termWrite(
|
||||
"""
|
||||
cd ${G.dataPath}
|
||||
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 --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:/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""");
|
||||
}
|
||||
|
||||
static Future<void> waitForConnection() async {
|
||||
// Future<bool> 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)),
|
||||
// Retry on SocketException or TimeoutException
|
||||
retryIf: (e) => e is SocketException || e is TimeoutException,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> launchBrowser() async {
|
||||
G.controller.loadRequest(Uri.parse(G.vncUrl));
|
||||
Navigator.push(G.homePageStateContext, MaterialPageRoute(builder: (context) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky,overlays: []);
|
||||
SystemChrome.setSystemUIChangeCallback((systemOverlaysAreVisible) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
SystemChrome.restoreSystemUIOverlays();
|
||||
});
|
||||
return Focus(
|
||||
onKey: (node, event) {
|
||||
// Allow webview to handle cursor keys. Without this, the
|
||||
// arrow keys seem to get "eaten" by Flutter and therefore
|
||||
// never reach the webview.
|
||||
// (https://github.com/flutter/flutter/issues/102505).
|
||||
if (!kIsWeb) {
|
||||
if ({
|
||||
LogicalKeyboardKey.arrowLeft,
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
LogicalKeyboardKey.arrowUp,
|
||||
LogicalKeyboardKey.arrowDown
|
||||
}.contains(event.logicalKey)) {
|
||||
return KeyEventResult.skipRemainingHandlers;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: WebViewWidget(controller: G.controller),
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
static Future<void> workflow() async {
|
||||
grantPermissions();
|
||||
await initTerminal();
|
||||
if (Util.isFirstTime()) {
|
||||
await setupBootstrap();
|
||||
}
|
||||
launchDefaultContainer();
|
||||
waitForConnection().then((value) => launchBrowser());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user