@duyxyz/saca 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (7) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +38 -38
  3. package/bin/cli.js +315 -315
  4. package/lib/adb.js +156 -156
  5. package/lib/state.js +144 -144
  6. package/lib/ui.js +205 -205
  7. package/package.json +50 -50
package/lib/adb.js CHANGED
@@ -1,156 +1,156 @@
1
- import { spawnSync } from 'child_process';
2
- import { existsSync } from 'fs';
3
- import { join, dirname } from 'path';
4
- import { fileURLToPath } from 'url';
5
-
6
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
-
8
- /**
9
- * Resolve path to adb binary.
10
- * Priority: system PATH → local adb.exe in project root
11
- */
12
- function getAdbPath() {
13
- const test = spawnSync('adb', ['version'], { encoding: 'utf8', windowsHide: true });
14
- if (test.status === 0) return 'adb';
15
-
16
- // Fallback: adb.exe bundled in project root
17
- const localAdb = join(__dirname, '..', 'adb.exe');
18
- if (existsSync(localAdb)) return localAdb;
19
-
20
- throw new Error('ADB not found in PATH or project directory.\nInstall ADB: https://developer.android.com/tools/adb');
21
- }
22
-
23
- function runAdb(adbPath, args) {
24
- return spawnSync(adbPath, args, {
25
- encoding: 'utf8',
26
- windowsHide: true,
27
- });
28
- }
29
-
30
- function readDeviceProp(adbPath, prop) {
31
- const result = runAdb(adbPath, ['shell', 'getprop', prop]);
32
- return (result.stdout || '').trim();
33
- }
34
-
35
- /**
36
- * Check ADB connection & detect connected device.
37
- * @returns {string} path to adb binary
38
- */
39
- export function checkAdb() {
40
- const adbPath = getAdbPath();
41
-
42
- const result = runAdb(adbPath, ['devices']);
43
-
44
- if (result.error) throw new Error(`ADB error: ${result.error.message}`);
45
-
46
- const lines = result.stdout
47
- .trim()
48
- .split('\n')
49
- .slice(1) // Skip "List of devices attached" header
50
- .map(l => l.trim())
51
- .filter(l => l && !l.startsWith('*'));
52
-
53
- if (lines.length === 0) {
54
- throw new Error(
55
- 'No device connected.\nEnsure USB Debugging is enabled and the device is authorized.'
56
- );
57
- }
58
-
59
- const [serial = '', state = 'unknown'] = lines[0].split(/\s+/);
60
-
61
- if (state === 'unauthorized') {
62
- throw new Error('Device detected but not authorized.\nAccept the RSA fingerprint prompt on the device.');
63
- }
64
-
65
- if (state !== 'device') {
66
- throw new Error(`Device is not ready.\nCurrent state: ${state}`);
67
- }
68
-
69
- const model = readDeviceProp(adbPath, 'ro.product.model') || 'Unknown device';
70
- const brand = readDeviceProp(adbPath, 'ro.product.brand');
71
- const androidVersion = readDeviceProp(adbPath, 'ro.build.version.release') || 'Unknown';
72
-
73
- return {
74
- adbPath,
75
- serial,
76
- state,
77
- model,
78
- brand,
79
- androidVersion,
80
- };
81
- }
82
-
83
- /**
84
- * Quick check for connected devices serials.
85
- * @returns {string[]} array of serial:state strings
86
- */
87
- export function getDeviceSummary() {
88
- try {
89
- const adbPath = getAdbPath();
90
- const result = runAdb(adbPath, ['devices']);
91
- if (result.error) return [];
92
-
93
- return result.stdout
94
- .trim()
95
- .split('\n')
96
- .slice(1)
97
- .map(l => l.trim())
98
- .filter(l => l && !l.startsWith('*'))
99
- .map(l => {
100
- const [serial = '', state = 'unknown'] = l.split(/\s+/);
101
- return `${serial}:${state}`;
102
- });
103
- } catch {
104
- return [];
105
- }
106
- }
107
-
108
- /**
109
- * Fetch installed packages split into system & 3rd party.
110
- * @param {string} adbPath
111
- * @returns {{ sysPackages: string[], userPackages: string[] }}
112
- */
113
- export function getPackages(adbPath) {
114
- const opts = { encoding: 'utf8', windowsHide: true };
115
-
116
- // -s = system packages, --user 0 = primary user
117
- const sysResult = spawnSync(
118
- adbPath,
119
- ['shell', 'pm', 'list', 'packages', '-s', '--user', '0'],
120
- opts
121
- );
122
-
123
- // -3 = 3rd party packages
124
- const userResult = spawnSync(
125
- adbPath,
126
- ['shell', 'pm', 'list', 'packages', '-3', '--user', '0'],
127
- opts
128
- );
129
-
130
- const parse = (stdout) =>
131
- stdout
132
- .split('\n')
133
- .map(l => l.replace('package:', '').trim())
134
- .filter(Boolean)
135
- .sort();
136
-
137
- return {
138
- sysPackages: parse(sysResult.stdout || ''),
139
- userPackages: parse(userResult.stdout || ''),
140
- };
141
- }
142
-
143
- /**
144
- * Uninstall a package for user 0 (disable without full removal).
145
- * @param {string} adbPath
146
- * @param {string} pkg package name
147
- * @returns {boolean} true if success
148
- */
149
- export function uninstallPackage(adbPath, pkg) {
150
- const result = spawnSync(
151
- adbPath,
152
- ['shell', 'pm', 'uninstall', '--user', '0', pkg],
153
- { encoding: 'utf8', windowsHide: true }
154
- );
155
- return (result.stdout || '').trim().toLowerCase().includes('success');
156
- }
1
+ import { spawnSync } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ /**
9
+ * Resolve path to adb binary.
10
+ * Priority: system PATH → local adb.exe in project root
11
+ */
12
+ function getAdbPath() {
13
+ const test = spawnSync('adb', ['version'], { encoding: 'utf8', windowsHide: true });
14
+ if (test.status === 0) return 'adb';
15
+
16
+ // Fallback: adb.exe bundled in project root
17
+ const localAdb = join(__dirname, '..', 'adb.exe');
18
+ if (existsSync(localAdb)) return localAdb;
19
+
20
+ throw new Error('ADB not found in PATH or project directory.\nInstall ADB: https://developer.android.com/tools/adb');
21
+ }
22
+
23
+ function runAdb(adbPath, args) {
24
+ return spawnSync(adbPath, args, {
25
+ encoding: 'utf8',
26
+ windowsHide: true,
27
+ });
28
+ }
29
+
30
+ function readDeviceProp(adbPath, prop) {
31
+ const result = runAdb(adbPath, ['shell', 'getprop', prop]);
32
+ return (result.stdout || '').trim();
33
+ }
34
+
35
+ /**
36
+ * Check ADB connection & detect connected device.
37
+ * @returns {string} path to adb binary
38
+ */
39
+ export function checkAdb() {
40
+ const adbPath = getAdbPath();
41
+
42
+ const result = runAdb(adbPath, ['devices']);
43
+
44
+ if (result.error) throw new Error(`ADB error: ${result.error.message}`);
45
+
46
+ const lines = result.stdout
47
+ .trim()
48
+ .split('\n')
49
+ .slice(1) // Skip "List of devices attached" header
50
+ .map(l => l.trim())
51
+ .filter(l => l && !l.startsWith('*'));
52
+
53
+ if (lines.length === 0) {
54
+ throw new Error(
55
+ 'No device connected.\nEnsure USB Debugging is enabled and the device is authorized.'
56
+ );
57
+ }
58
+
59
+ const [serial = '', state = 'unknown'] = lines[0].split(/\s+/);
60
+
61
+ if (state === 'unauthorized') {
62
+ throw new Error('Device detected but not authorized.\nAccept the RSA fingerprint prompt on the device.');
63
+ }
64
+
65
+ if (state !== 'device') {
66
+ throw new Error(`Device is not ready.\nCurrent state: ${state}`);
67
+ }
68
+
69
+ const model = readDeviceProp(adbPath, 'ro.product.model') || 'Unknown device';
70
+ const brand = readDeviceProp(adbPath, 'ro.product.brand');
71
+ const androidVersion = readDeviceProp(adbPath, 'ro.build.version.release') || 'Unknown';
72
+
73
+ return {
74
+ adbPath,
75
+ serial,
76
+ state,
77
+ model,
78
+ brand,
79
+ androidVersion,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Quick check for connected devices serials.
85
+ * @returns {string[]} array of serial:state strings
86
+ */
87
+ export function getDeviceSummary() {
88
+ try {
89
+ const adbPath = getAdbPath();
90
+ const result = runAdb(adbPath, ['devices']);
91
+ if (result.error) return [];
92
+
93
+ return result.stdout
94
+ .trim()
95
+ .split('\n')
96
+ .slice(1)
97
+ .map(l => l.trim())
98
+ .filter(l => l && !l.startsWith('*'))
99
+ .map(l => {
100
+ const [serial = '', state = 'unknown'] = l.split(/\s+/);
101
+ return `${serial}:${state}`;
102
+ });
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Fetch installed packages split into system & 3rd party.
110
+ * @param {string} adbPath
111
+ * @returns {{ sysPackages: string[], userPackages: string[] }}
112
+ */
113
+ export function getPackages(adbPath) {
114
+ const opts = { encoding: 'utf8', windowsHide: true };
115
+
116
+ // -s = system packages, --user 0 = primary user
117
+ const sysResult = spawnSync(
118
+ adbPath,
119
+ ['shell', 'pm', 'list', 'packages', '-s', '--user', '0'],
120
+ opts
121
+ );
122
+
123
+ // -3 = 3rd party packages
124
+ const userResult = spawnSync(
125
+ adbPath,
126
+ ['shell', 'pm', 'list', 'packages', '-3', '--user', '0'],
127
+ opts
128
+ );
129
+
130
+ const parse = (stdout) =>
131
+ stdout
132
+ .split('\n')
133
+ .map(l => l.replace('package:', '').trim())
134
+ .filter(Boolean)
135
+ .sort();
136
+
137
+ return {
138
+ sysPackages: parse(sysResult.stdout || ''),
139
+ userPackages: parse(userResult.stdout || ''),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Uninstall a package for user 0 (disable without full removal).
145
+ * @param {string} adbPath
146
+ * @param {string} pkg package name
147
+ * @returns {boolean} true if success
148
+ */
149
+ export function uninstallPackage(adbPath, pkg) {
150
+ const result = spawnSync(
151
+ adbPath,
152
+ ['shell', 'pm', 'uninstall', '--user', '0', pkg],
153
+ { encoding: 'utf8', windowsHide: true }
154
+ );
155
+ return (result.stdout || '').trim().toLowerCase().includes('success');
156
+ }
package/lib/state.js CHANGED
@@ -1,144 +1,144 @@
1
- export const state = {
2
- phase: 'boot',
3
- screen: 'loading',
4
- message: 'Initializing session...',
5
- error: '',
6
- device: null,
7
- items: [],
8
- filteredByGroup: {
9
- System: [],
10
- User: [],
11
- },
12
- cursorByGroup: {
13
- System: 0,
14
- User: 0,
15
- },
16
- scrollByGroup: {
17
- System: 0,
18
- User: 0,
19
- },
20
- activePane: 'System',
21
- query: '',
22
- selected: new Set(),
23
- pending: [],
24
- currentPackage: '',
25
- progressIndex: 0,
26
- successCount: 0,
27
- failCount: 0,
28
- running: true,
29
- };
30
-
31
- export function clamp(value, min, max) {
32
- return Math.max(min, Math.min(max, value));
33
- }
34
-
35
- export function createItems(sysPackages, userPackages) {
36
- return [
37
- ...sysPackages.map((pkg) => ({ pkg, group: 'System' })),
38
- ...userPackages.map((pkg) => ({ pkg, group: 'User' })),
39
- ];
40
- }
41
-
42
- export function filterItems() {
43
- const query = state.query.trim().toLowerCase();
44
- state.filteredByGroup.System = state.items.filter((item) => (
45
- item.group === 'System' &&
46
- (!query || item.pkg.toLowerCase().includes(query) || item.group.toLowerCase().includes(query))
47
- ));
48
- state.filteredByGroup.User = state.items.filter((item) => (
49
- item.group === 'User' &&
50
- (!query || item.pkg.toLowerCase().includes(query) || item.group.toLowerCase().includes(query))
51
- ));
52
-
53
- for (const group of ['System', 'User']) {
54
- const list = state.filteredByGroup[group];
55
- if (list.length === 0) {
56
- state.cursorByGroup[group] = 0;
57
- state.scrollByGroup[group] = 0;
58
- continue;
59
- }
60
- state.cursorByGroup[group] = clamp(state.cursorByGroup[group], 0, list.length - 1);
61
- syncScroll(group);
62
- }
63
-
64
- if (state.filteredByGroup[state.activePane].length === 0) {
65
- state.activePane = state.filteredByGroup.System.length > 0 ? 'System' : 'User';
66
- }
67
- }
68
-
69
- let getListHeightRef = () => 0;
70
-
71
- export function setListHeightResolver(fn) {
72
- getListHeightRef = fn;
73
- }
74
-
75
- export function syncScroll(group = state.activePane) {
76
- const list = state.filteredByGroup[group];
77
- const listHeight = getListHeightRef();
78
- if (listHeight <= 0 || list.length === 0) {
79
- state.scrollByGroup[group] = 0;
80
- return;
81
- }
82
- if (state.cursorByGroup[group] < state.scrollByGroup[group]) {
83
- state.scrollByGroup[group] = state.cursorByGroup[group];
84
- }
85
- const lastVisible = state.scrollByGroup[group] + listHeight - 1;
86
- if (state.cursorByGroup[group] > lastVisible) {
87
- state.scrollByGroup[group] = state.cursorByGroup[group] - listHeight + 1;
88
- }
89
- const maxScroll = Math.max(0, list.length - listHeight);
90
- state.scrollByGroup[group] = clamp(state.scrollByGroup[group], 0, maxScroll);
91
- }
92
-
93
- export function moveCursor(delta) {
94
- const group = state.activePane;
95
- const list = state.filteredByGroup[group];
96
- if (list.length === 0) return;
97
- state.cursorByGroup[group] = clamp(state.cursorByGroup[group] + delta, 0, list.length - 1);
98
- syncScroll(group);
99
- }
100
-
101
- export function switchPane(direction) {
102
- const oldPane = state.activePane;
103
- const nextPane = direction > 0 ? 'User' : 'System';
104
-
105
- if (oldPane === nextPane) return;
106
-
107
- const nextList = state.filteredByGroup[nextPane];
108
- if (nextList.length > 0) {
109
- // Get relative vertical position on screen
110
- const relativeIndex = state.cursorByGroup[oldPane] - state.scrollByGroup[oldPane];
111
-
112
- state.activePane = nextPane;
113
-
114
- // Apply the same relative position to the new pane
115
- state.cursorByGroup[nextPane] = clamp(
116
- state.scrollByGroup[nextPane] + relativeIndex,
117
- 0,
118
- nextList.length - 1
119
- );
120
-
121
- syncScroll(nextPane);
122
- }
123
- }
124
-
125
- export function toggleCurrent() {
126
- const item = getCurrentItem();
127
- if (!item) return;
128
- if (state.selected.has(item.pkg)) {
129
- state.selected.delete(item.pkg);
130
- } else {
131
- state.selected.add(item.pkg);
132
- }
133
- }
134
-
135
- export function getCurrentItem() {
136
- const group = state.activePane;
137
- return state.filteredByGroup[group][state.cursorByGroup[group]] || null;
138
- }
139
-
140
- export function summaryStats() {
141
- const systemCount = state.items.filter((item) => item.group === 'System').length;
142
- const userCount = state.items.length - systemCount;
143
- return { systemCount, userCount };
144
- }
1
+ export const state = {
2
+ phase: 'boot',
3
+ screen: 'loading',
4
+ message: 'Initializing session...',
5
+ error: '',
6
+ device: null,
7
+ items: [],
8
+ filteredByGroup: {
9
+ System: [],
10
+ User: [],
11
+ },
12
+ cursorByGroup: {
13
+ System: 0,
14
+ User: 0,
15
+ },
16
+ scrollByGroup: {
17
+ System: 0,
18
+ User: 0,
19
+ },
20
+ activePane: 'System',
21
+ query: '',
22
+ selected: new Set(),
23
+ pending: [],
24
+ currentPackage: '',
25
+ progressIndex: 0,
26
+ successCount: 0,
27
+ failCount: 0,
28
+ running: true,
29
+ };
30
+
31
+ export function clamp(value, min, max) {
32
+ return Math.max(min, Math.min(max, value));
33
+ }
34
+
35
+ export function createItems(sysPackages, userPackages) {
36
+ return [
37
+ ...sysPackages.map((pkg) => ({ pkg, group: 'System' })),
38
+ ...userPackages.map((pkg) => ({ pkg, group: 'User' })),
39
+ ];
40
+ }
41
+
42
+ export function filterItems() {
43
+ const query = state.query.trim().toLowerCase();
44
+ state.filteredByGroup.System = state.items.filter((item) => (
45
+ item.group === 'System' &&
46
+ (!query || item.pkg.toLowerCase().includes(query) || item.group.toLowerCase().includes(query))
47
+ ));
48
+ state.filteredByGroup.User = state.items.filter((item) => (
49
+ item.group === 'User' &&
50
+ (!query || item.pkg.toLowerCase().includes(query) || item.group.toLowerCase().includes(query))
51
+ ));
52
+
53
+ for (const group of ['System', 'User']) {
54
+ const list = state.filteredByGroup[group];
55
+ if (list.length === 0) {
56
+ state.cursorByGroup[group] = 0;
57
+ state.scrollByGroup[group] = 0;
58
+ continue;
59
+ }
60
+ state.cursorByGroup[group] = clamp(state.cursorByGroup[group], 0, list.length - 1);
61
+ syncScroll(group);
62
+ }
63
+
64
+ if (state.filteredByGroup[state.activePane].length === 0) {
65
+ state.activePane = state.filteredByGroup.System.length > 0 ? 'System' : 'User';
66
+ }
67
+ }
68
+
69
+ let getListHeightRef = () => 0;
70
+
71
+ export function setListHeightResolver(fn) {
72
+ getListHeightRef = fn;
73
+ }
74
+
75
+ export function syncScroll(group = state.activePane) {
76
+ const list = state.filteredByGroup[group];
77
+ const listHeight = getListHeightRef();
78
+ if (listHeight <= 0 || list.length === 0) {
79
+ state.scrollByGroup[group] = 0;
80
+ return;
81
+ }
82
+ if (state.cursorByGroup[group] < state.scrollByGroup[group]) {
83
+ state.scrollByGroup[group] = state.cursorByGroup[group];
84
+ }
85
+ const lastVisible = state.scrollByGroup[group] + listHeight - 1;
86
+ if (state.cursorByGroup[group] > lastVisible) {
87
+ state.scrollByGroup[group] = state.cursorByGroup[group] - listHeight + 1;
88
+ }
89
+ const maxScroll = Math.max(0, list.length - listHeight);
90
+ state.scrollByGroup[group] = clamp(state.scrollByGroup[group], 0, maxScroll);
91
+ }
92
+
93
+ export function moveCursor(delta) {
94
+ const group = state.activePane;
95
+ const list = state.filteredByGroup[group];
96
+ if (list.length === 0) return;
97
+ state.cursorByGroup[group] = clamp(state.cursorByGroup[group] + delta, 0, list.length - 1);
98
+ syncScroll(group);
99
+ }
100
+
101
+ export function switchPane(direction) {
102
+ const oldPane = state.activePane;
103
+ const nextPane = direction > 0 ? 'User' : 'System';
104
+
105
+ if (oldPane === nextPane) return;
106
+
107
+ const nextList = state.filteredByGroup[nextPane];
108
+ if (nextList.length > 0) {
109
+ // Get relative vertical position on screen
110
+ const relativeIndex = state.cursorByGroup[oldPane] - state.scrollByGroup[oldPane];
111
+
112
+ state.activePane = nextPane;
113
+
114
+ // Apply the same relative position to the new pane
115
+ state.cursorByGroup[nextPane] = clamp(
116
+ state.scrollByGroup[nextPane] + relativeIndex,
117
+ 0,
118
+ nextList.length - 1
119
+ );
120
+
121
+ syncScroll(nextPane);
122
+ }
123
+ }
124
+
125
+ export function toggleCurrent() {
126
+ const item = getCurrentItem();
127
+ if (!item) return;
128
+ if (state.selected.has(item.pkg)) {
129
+ state.selected.delete(item.pkg);
130
+ } else {
131
+ state.selected.add(item.pkg);
132
+ }
133
+ }
134
+
135
+ export function getCurrentItem() {
136
+ const group = state.activePane;
137
+ return state.filteredByGroup[group][state.cursorByGroup[group]] || null;
138
+ }
139
+
140
+ export function summaryStats() {
141
+ const systemCount = state.items.filter((item) => item.group === 'System').length;
142
+ const userCount = state.items.length - systemCount;
143
+ return { systemCount, userCount };
144
+ }