@duyxyz/saca 1.0.0

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.
package/AdbWinApi.dll ADDED
Binary file
Binary file
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 duyxyz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # SACA (System Adb Cleaner Assistant)
2
+
3
+ **SACA** is a high-performance, full-screen TUI tool to quickly uninstall Android bloatware via ADB.
4
+
5
+
6
+ ## Get Started
7
+
8
+ Run instantly with npx
9
+ ```bash
10
+ npx github:duyxyz/saca
11
+ ```
12
+
13
+ Install globally with npm
14
+ ```bash
15
+ npm install -g github:duyxyz/saca
16
+ ```
17
+
18
+
19
+ ## Controls
20
+
21
+ - **Arrows / Tab**: Navigate & Switch Panes
22
+ - **Space**: Select Apps
23
+ - **Enter**: Uninstall
24
+ - **Esc**: Clear Filter / Back
25
+ - **Ctrl + Q**: Quit
26
+
27
+
28
+
29
+ ## Requirements
30
+ - **Node.js** ≥ 18
31
+ - **ADB** installed and in PATH
32
+ - **USB Debugging** enabled on your device
33
+
package/adb.exe ADDED
Binary file
package/bin/cli.js ADDED
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+
3
+ import readline from 'readline';
4
+ import { checkAdb, getPackages, uninstallPackage, getDeviceSummary } from '../lib/adb.js';
5
+ import {
6
+ state,
7
+ filterItems,
8
+ moveCursor,
9
+ switchPane,
10
+ toggleCurrent,
11
+ createItems,
12
+ setListHeightResolver,
13
+ } from '../lib/state.js';
14
+ import {
15
+ render,
16
+ getListHeight,
17
+ } from '../lib/ui.js';
18
+
19
+ const KEY = {
20
+ CTRL_C: '\u0003',
21
+ CTRL_Q: '\u0011',
22
+ ESC: '\u001b',
23
+ RETURN: '\r',
24
+ NEWLINE: '\n',
25
+ BACKSPACE: '\u007f',
26
+ BACKSPACE_WIN: '\b',
27
+ SPACE: ' ',
28
+ TAB: '\t',
29
+ };
30
+
31
+ const KEY_SEQUENCES = {
32
+ up: ['\u001b[A', '\u001bOA', '\u0000H', '\u00e0H'],
33
+ down: ['\u001b[B', '\u001bOB', '\u0000P', '\u00e0P'],
34
+ left: ['\u001b[D', '\u001bOD', '\u0000K', '\u00e0K', '\u001b[Z'],
35
+ right: ['\u001b[C', '\u001bOC', '\u0000M', '\u00e0M', KEY.TAB],
36
+ pageup: ['\u001b[5~', '\u0000I', '\u00e0I'],
37
+ pagedown: ['\u001b[6~', '\u0000Q', '\u00e0Q'],
38
+ };
39
+
40
+ // Connect state logic with UI config
41
+ setListHeightResolver(getListHeight);
42
+
43
+ let isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
44
+ let rawModeEnabled = false;
45
+ let lastSummary = '';
46
+
47
+ async function pollDeviceStatus() {
48
+ if (!state.running || state.screen === 'running' || state.screen === 'loading') return;
49
+
50
+ const devices = getDeviceSummary();
51
+ const currentSummary = devices.length > 0 ? devices[0] : '';
52
+
53
+ if (currentSummary !== lastSummary) {
54
+ const isNewConnection = !lastSummary && currentSummary;
55
+ lastSummary = currentSummary;
56
+
57
+ if (!currentSummary) {
58
+ state.screen = 'error';
59
+ state.error = 'Device disconnected.\nConnect a device to continue.';
60
+ state.device = null;
61
+ render();
62
+ } else if (isNewConnection || (state.device && state.device.serial !== currentSummary.split(':')[0])) {
63
+ try {
64
+ state.device = checkAdb();
65
+ await refreshList();
66
+ } catch (error) {
67
+ state.screen = 'error';
68
+ state.error = error.message;
69
+ render();
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ function enterAltScreen() {
76
+ if (!isInteractive) return;
77
+ process.stdout.write('\x1b[?1049h\x1b[?25l');
78
+ }
79
+
80
+ function leaveAltScreen() {
81
+ if (!isInteractive) return;
82
+ process.stdout.write('\x1b[?25h\x1b[?1049l');
83
+ }
84
+
85
+ function cleanupAndExit(code = 0) {
86
+ state.running = false;
87
+ if (isInteractive) {
88
+ process.stdin.off('keypress', onKeypress);
89
+ process.stdout.off('resize', render);
90
+ if (rawModeEnabled) {
91
+ process.stdin.setRawMode(false);
92
+ rawModeEnabled = false;
93
+ }
94
+ leaveAltScreen();
95
+ }
96
+ process.exit(code);
97
+ }
98
+
99
+ function isKeyMatch(key, input, name, sequences = []) {
100
+ if (key?.name === name) return true;
101
+ return sequences.includes(input);
102
+ }
103
+
104
+ async function refreshList() {
105
+ state.message = 'Refreshing package list...';
106
+ state.screen = 'loading';
107
+ render();
108
+
109
+ try {
110
+ const { sysPackages, userPackages } = getPackages(state.device.adbPath);
111
+ state.items = createItems(sysPackages, userPackages);
112
+ state.selected.clear();
113
+ state.pending = [];
114
+ state.currentPackage = '';
115
+ state.progressIndex = 0;
116
+ state.successCount = 0;
117
+ state.failCount = 0;
118
+ filterItems();
119
+ state.screen = 'list';
120
+ } catch (error) {
121
+ state.screen = 'error';
122
+ state.error = error.message;
123
+ }
124
+ render();
125
+ }
126
+
127
+ function onKeypress(str, key) {
128
+ if (!state.running) return;
129
+ const input = typeof str === 'string' ? str : '';
130
+ const lowerInput = input.toLowerCase();
131
+
132
+ if ((key && key.ctrl && key.name === 'c') || input === KEY.CTRL_C) {
133
+ cleanupAndExit(0);
134
+ return;
135
+ }
136
+
137
+ if (state.screen === 'error' || state.screen === 'summary') {
138
+ if (state.screen === 'summary' && (isKeyMatch(key, input, 'return', [KEY.RETURN, KEY.NEWLINE]) || isKeyMatch(key, input, 'escape', [KEY.ESC]))) {
139
+ void refreshList();
140
+ return;
141
+ }
142
+ if (!key || ['return', 'escape'].includes(key.name) || lowerInput === KEY.CTRL_Q) cleanupAndExit(0);
143
+ return;
144
+ }
145
+
146
+ if (state.screen === 'running') return;
147
+
148
+ if ((key && key.ctrl && key.name === 'q') || input === KEY.CTRL_Q) {
149
+ cleanupAndExit(0);
150
+ return;
151
+ }
152
+
153
+ if (state.screen === 'confirm') {
154
+ if (isKeyMatch(key, input, 'return', [KEY.RETURN, KEY.NEWLINE])) {
155
+ void startUninstall();
156
+ return;
157
+ }
158
+ if (isKeyMatch(key, input, 'escape', [KEY.ESC])) {
159
+ state.screen = 'list';
160
+ render();
161
+ }
162
+ return;
163
+ }
164
+
165
+ if (isKeyMatch(key, input, 'up', KEY_SEQUENCES.up)) {
166
+ moveCursor(-1);
167
+ render();
168
+ return;
169
+ }
170
+ if (isKeyMatch(key, input, 'down', KEY_SEQUENCES.down)) {
171
+ moveCursor(1);
172
+ render();
173
+ return;
174
+ }
175
+ if (isKeyMatch(key, input, 'left', KEY_SEQUENCES.left)) {
176
+ switchPane(-1);
177
+ render();
178
+ return;
179
+ }
180
+ if (isKeyMatch(key, input, 'tab', [KEY.TAB])) {
181
+ switchPane(state.activePane === 'System' ? 1 : -1);
182
+ render();
183
+ return;
184
+ }
185
+ if (isKeyMatch(key, input, 'right', KEY_SEQUENCES.right)) {
186
+ switchPane(1);
187
+ render();
188
+ return;
189
+ }
190
+ if (isKeyMatch(key, input, 'pageup', KEY_SEQUENCES.pageup)) {
191
+ moveCursor(-getListHeight());
192
+ render();
193
+ return;
194
+ }
195
+ if (isKeyMatch(key, input, 'pagedown', KEY_SEQUENCES.pagedown)) {
196
+ moveCursor(getListHeight());
197
+ render();
198
+ return;
199
+ }
200
+ if (isKeyMatch(key, input, 'return', [KEY.RETURN, KEY.NEWLINE])) {
201
+ if (state.selected.size > 0) {
202
+ state.pending = Array.from(state.selected);
203
+ state.screen = 'confirm';
204
+ render();
205
+ }
206
+ return;
207
+ }
208
+ if (isKeyMatch(key, input, 'backspace', [KEY.BACKSPACE, KEY.BACKSPACE_WIN])) {
209
+ if (state.query.length > 0) {
210
+ state.query = state.query.slice(0, -1);
211
+ filterItems();
212
+ render();
213
+ }
214
+ return;
215
+ }
216
+ if (isKeyMatch(key, input, 'escape', [KEY.ESC])) {
217
+ if (state.query) {
218
+ state.query = '';
219
+ filterItems();
220
+ render();
221
+ }
222
+ return;
223
+ }
224
+ if (input === KEY.SPACE) {
225
+ toggleCurrent();
226
+ render();
227
+ return;
228
+ }
229
+ if (input && !key?.ctrl && !key?.meta && /^[\w.-]$/i.test(input)) {
230
+ state.query += input;
231
+ filterItems();
232
+ render();
233
+ }
234
+ }
235
+
236
+ async function startUninstall() {
237
+ state.screen = 'running';
238
+ state.progressIndex = 0;
239
+ state.successCount = 0;
240
+ state.failCount = 0;
241
+ render();
242
+ for (const pkg of state.pending) {
243
+ state.progressIndex += 1;
244
+ state.currentPackage = pkg;
245
+ render();
246
+ const success = uninstallPackage(state.device.adbPath, pkg);
247
+ if (success) state.successCount += 1;
248
+ else state.failCount += 1;
249
+ }
250
+ state.screen = 'summary';
251
+ state.currentPackage = '';
252
+ render();
253
+ }
254
+
255
+ function printFallback(message) {
256
+ process.stdout.write(`${message}\n`);
257
+ }
258
+
259
+ async function bootstrap() {
260
+ if (!isInteractive) {
261
+ printFallback('This command now expects an interactive terminal (TTY).');
262
+ process.exit(1);
263
+ }
264
+ enterAltScreen();
265
+ readline.emitKeypressEvents(process.stdin);
266
+ process.stdin.setRawMode(true);
267
+ rawModeEnabled = true;
268
+ process.stdin.on('keypress', onKeypress);
269
+ process.stdout.on('resize', render);
270
+ render();
271
+
272
+ state.message = 'Checking ADB connection...';
273
+ render();
274
+ try {
275
+ state.device = checkAdb();
276
+ lastSummary = `${state.device.serial}:${state.device.state}`;
277
+ setInterval(pollDeviceStatus, 2000);
278
+ } catch (error) {
279
+ state.screen = 'error';
280
+ state.error = error.message;
281
+ render();
282
+ return;
283
+ }
284
+
285
+ state.message = 'Loading installed packages...';
286
+ render();
287
+ try {
288
+ const { sysPackages, userPackages } = getPackages(state.device.adbPath);
289
+ state.items = createItems(sysPackages, userPackages);
290
+ filterItems();
291
+ state.screen = 'list';
292
+ render();
293
+ } catch (error) {
294
+ state.screen = 'error';
295
+ state.error = error.message;
296
+ render();
297
+ }
298
+ }
299
+
300
+ process.on('SIGINT', () => cleanupAndExit(0));
301
+ process.on('uncaughtException', (error) => {
302
+ if (isInteractive) leaveAltScreen();
303
+ console.error(error);
304
+ process.exit(1);
305
+ });
306
+ process.on('exit', () => {
307
+ if (isInteractive && rawModeEnabled) process.stdin.setRawMode(false);
308
+ leaveAltScreen();
309
+ });
310
+
311
+ bootstrap().catch((error) => {
312
+ leaveAltScreen();
313
+ console.error(error);
314
+ process.exit(1);
315
+ });
package/lib/adb.js ADDED
@@ -0,0 +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
+ }
package/lib/state.js ADDED
@@ -0,0 +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
+ }
package/lib/ui.js ADDED
@@ -0,0 +1,205 @@
1
+ import chalk from 'chalk';
2
+ import { state, summaryStats, getCurrentItem } from './state.js';
3
+
4
+ export function stripAnsi(text) {
5
+ return String(text).replace(/\x1B\[[0-9;]*m/g, '');
6
+ }
7
+
8
+ export function pad(text, width) {
9
+ const plainLength = stripAnsi(text).length;
10
+ if (plainLength >= width) return text;
11
+ return text + ' '.repeat(width - plainLength);
12
+ }
13
+
14
+ export function truncate(text, width) {
15
+ if (width <= 0) return '';
16
+ const plain = stripAnsi(text);
17
+ if (plain.length <= width) return pad(text, width);
18
+ if (width === 1) return '…';
19
+ return plain.slice(0, width - 1) + '…';
20
+ }
21
+
22
+ export function line(width, left = '', right = '') {
23
+ const rightText = right ? ` ${right}` : '';
24
+ const available = Math.max(0, width - stripAnsi(rightText).length);
25
+ return truncate(left, available) + rightText;
26
+ }
27
+
28
+ export function fullWidthRule(width, color = chalk.dim) {
29
+ return color('─'.repeat(Math.max(0, width)));
30
+ }
31
+
32
+ export function getTerminalSize() {
33
+ return {
34
+ width: process.stdout.columns || 100,
35
+ height: process.stdout.rows || 32,
36
+ };
37
+ }
38
+
39
+ export function getListHeight() {
40
+ const { height } = getTerminalSize();
41
+ return Math.max(1, height - 10);
42
+ }
43
+
44
+ export function renderLoading(width, height) {
45
+ const rows = [];
46
+ rows.push(chalk.white.bold('SACA'));
47
+ rows.push(chalk.dim('Full-screen ADB debloat session'));
48
+ rows.push('');
49
+ rows.push(chalk.cyan(state.message));
50
+ while (rows.length < height - 2) rows.push('');
51
+ rows.push(fullWidthRule(width));
52
+ rows.push(chalk.dim('Waiting for ADB response...'));
53
+ return rows;
54
+ }
55
+
56
+ export function renderError(width, height) {
57
+ const rows = [];
58
+ rows.push(chalk.red.bold('Connection Error'));
59
+ rows.push('');
60
+ for (const part of state.error.split('\n')) rows.push(chalk.white(part));
61
+ rows.push('');
62
+ rows.push(chalk.dim('Press Ctrl+Q or Ctrl+C to exit.'));
63
+ while (rows.length < height) rows.push('');
64
+ return rows.slice(0, height);
65
+ }
66
+
67
+ export function renderHeader(width) {
68
+ const device = state.device;
69
+ const deviceName = device ? [device.brand, device.model].filter(Boolean).join(' ').trim() : 'No device';
70
+ const right = state.query ? chalk.yellow(`filter: ${state.query}`) : chalk.dim('filter: all');
71
+ return [
72
+ line(width, chalk.white.bold('SACA'), chalk.dim('FULL SCREEN')),
73
+ line(width, chalk.dim(deviceName || 'Unknown device'), right),
74
+ fullWidthRule(width),
75
+ ];
76
+ }
77
+
78
+ export function renderFooter(width, left = '', right = '') {
79
+ return [line(width, chalk.dim(left), chalk.dim(right))];
80
+ }
81
+
82
+ export function renderList(width, height) {
83
+ const rows = [];
84
+ const { systemCount, userCount } = summaryStats();
85
+ const selectedCount = state.selected.size;
86
+ const current = getCurrentItem();
87
+ const systemVisible = state.filteredByGroup.System.slice(state.scrollByGroup.System, state.scrollByGroup.System + Math.max(4, height - 6));
88
+ const userVisible = state.filteredByGroup.User.slice(state.scrollByGroup.User, state.scrollByGroup.User + Math.max(4, height - 6));
89
+
90
+ rows.push(line(width, chalk.white(`Packages ${chalk.dim(`(${state.items.length})`)}`), chalk.dim(`selected ${selectedCount}`)));
91
+ rows.push(line(width, chalk.dim(`system ${state.filteredByGroup.System.length}/${systemCount} | user ${state.filteredByGroup.User.length}/${userCount}`), chalk.dim(`focus ${state.activePane.toLowerCase()}`)));
92
+ rows.push(fullWidthRule(width));
93
+
94
+ const leftWidth = Math.max(20, Math.floor((width - 3) / 2));
95
+ const rightWidth = Math.max(20, width - leftWidth - 3);
96
+ const maxPaneHeight = Math.max(4, height - 5);
97
+
98
+ const renderPane = (group, visible, paneWidth) => {
99
+ const paneRows = [];
100
+ const isActivePane = state.activePane === group;
101
+ const title = isActivePane ? chalk.cyan.bold(`${group} Apps`) : chalk.white(`${group} Apps`);
102
+ const count = chalk.dim(`(${state.filteredByGroup[group].length})`);
103
+ paneRows.push(line(paneWidth, title, count));
104
+ paneRows.push(chalk.dim('─'.repeat(Math.max(0, paneWidth))));
105
+
106
+ if (visible.length === 0) {
107
+ paneRows.push(chalk.yellow('No matches'));
108
+ paneRows.push(chalk.dim('Adjust filter'));
109
+ } else {
110
+ for (let index = 0; index < visible.length; index++) {
111
+ const absoluteIndex = state.scrollByGroup[group] + index;
112
+ const item = visible[index];
113
+ const isActive = isActivePane && absoluteIndex === state.cursorByGroup[group];
114
+ const isSelected = state.selected.has(item.pkg);
115
+ const marker = isActive ? chalk.cyan('❯') : ' ';
116
+ const bullet = isSelected ? chalk.green('●') : chalk.dim('○');
117
+ const pkgColor = isSelected ? chalk.green : (isActive ? chalk.white : chalk.dim);
118
+ const rowText = `${marker} ${bullet} ${item.pkg}`;
119
+ const formattedRow = line(paneWidth, isActive ? chalk.bold(rowText) : rowText);
120
+ paneRows.push(pkgColor(formattedRow));
121
+ }
122
+ }
123
+ return paneRows.slice(0, maxPaneHeight);
124
+ };
125
+
126
+ const leftPane = renderPane('System', systemVisible, leftWidth);
127
+ const rightPane = renderPane('User', userVisible, rightWidth);
128
+ const paneHeight = Math.max(leftPane.length, rightPane.length);
129
+
130
+ for (let i = 0; i < paneHeight; i++) {
131
+ rows.push(`${pad(leftPane[i] || '', leftWidth)} ${chalk.dim('│')} ${pad(rightPane[i] || '', rightWidth)}`);
132
+ }
133
+
134
+ const info = current ? `${current.pkg} | ${current.group} app` : 'No package selected';
135
+ while (rows.length < height - 2) rows.push('');
136
+ rows.push(fullWidthRule(width));
137
+ rows.push(...renderFooter(width, info, 'up/down move | left/right switch | space toggle | enter review | esc clear | ctrl+q quit'));
138
+ return rows.slice(0, height);
139
+ }
140
+
141
+ export function renderConfirm(width, height) {
142
+ const rows = [];
143
+ const pending = state.pending;
144
+ rows.push(chalk.white.bold('Review Selection'));
145
+ rows.push(chalk.dim(`Ready to uninstall ${pending.length} package(s) for user 0.`));
146
+ rows.push(fullWidthRule(width));
147
+ for (const pkg of pending.slice(0, Math.max(3, height - 6))) rows.push(chalk.red(`- ${pkg}`));
148
+ if (pending.length > Math.max(3, height - 6)) rows.push(chalk.dim(`... and ${pending.length - Math.max(3, height - 6)} more`));
149
+ while (rows.length < height - 2) rows.push('');
150
+ rows.push(fullWidthRule(width));
151
+ rows.push(...renderFooter(width, 'Press Enter to confirm', 'Enter confirm | Esc back | ctrl+q quit'));
152
+ return rows.slice(0, height);
153
+ }
154
+
155
+ export function renderRunning(width, height) {
156
+ const rows = [];
157
+ rows.push(chalk.white.bold('Uninstalling Packages'));
158
+ rows.push(chalk.dim(`Processing ${state.progressIndex}/${state.pending.length}`));
159
+ rows.push(fullWidthRule(width));
160
+ const barWidth = Math.max(10, width - 10);
161
+ const ratio = state.pending.length === 0 ? 0 : state.progressIndex / state.pending.length;
162
+ const filled = Math.round(barWidth * ratio);
163
+ const bar = chalk.cyan('█'.repeat(filled)) + chalk.dim('░'.repeat(Math.max(0, barWidth - filled)));
164
+ rows.push(bar);
165
+ rows.push('');
166
+ rows.push(line(width, chalk.white(state.currentPackage || 'Preparing...'), chalk.dim(`${state.successCount} ok / ${state.failCount} failed`)));
167
+ while (rows.length < height - 2) rows.push('');
168
+ rows.push(fullWidthRule(width));
169
+ rows.push(...renderFooter(width, 'Working...', 'Do not disconnect the device'));
170
+ return rows.slice(0, height);
171
+ }
172
+
173
+ export function renderSummary(width, height) {
174
+ const rows = [];
175
+ rows.push(chalk.white.bold('Summary'));
176
+ rows.push(chalk.green(`Success: ${state.successCount}`));
177
+ rows.push(state.failCount > 0 ? chalk.red(`Failed: ${state.failCount}`) : chalk.dim('Failed: 0'));
178
+ rows.push(fullWidthRule(width));
179
+ rows.push(chalk.dim('Press Enter or Esc to return to the list. Press Ctrl+Q or Ctrl+C to exit.'));
180
+ while (rows.length < height) rows.push('');
181
+ return rows.slice(0, height);
182
+ }
183
+
184
+ let lastFrame = '';
185
+
186
+ export function render() {
187
+ if (!state.running) return;
188
+ const { width, height } = getTerminalSize();
189
+ const bodyHeight = Math.max(6, height - 3);
190
+ let rows;
191
+ if (state.screen === 'loading') rows = renderLoading(width, height);
192
+ else if (state.screen === 'error') rows = renderError(width, height);
193
+ else if (state.screen === 'list') rows = [...renderHeader(width), ...renderList(width, bodyHeight)];
194
+ else if (state.screen === 'confirm') rows = [...renderHeader(width), ...renderConfirm(width, bodyHeight)];
195
+ else if (state.screen === 'running') rows = [...renderHeader(width), ...renderRunning(width, bodyHeight)];
196
+ else rows = [...renderHeader(width), ...renderSummary(width, bodyHeight)];
197
+
198
+ while (rows.length < height) rows.push('');
199
+ const frame = rows.slice(0, height).map((row) => truncate(row, width)).join('\n');
200
+
201
+ if (frame !== lastFrame) {
202
+ process.stdout.write('\x1b[H' + frame + '\x1b[J');
203
+ lastFrame = frame;
204
+ }
205
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@duyxyz/saca",
3
+ "version": "1.0.0",
4
+ "description": "Interactive CLI tool to list and uninstall Android apps via ADB",
5
+ "type": "module",
6
+ "bin": {
7
+ "saca": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "README.md",
13
+ "LICENSE",
14
+ "adb.exe",
15
+ "AdbWinApi.dll",
16
+ "AdbWinUsbApi.dll"
17
+ ],
18
+ "scripts": {
19
+ "start": "node bin/cli.js"
20
+ },
21
+ "keywords": [
22
+ "adb",
23
+ "android",
24
+ "uninstall",
25
+ "cli",
26
+ "debloat"
27
+ ],
28
+ "author": "",
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "@inquirer/checkbox": "^4.0.2",
32
+ "@inquirer/confirm": "^5.1.6",
33
+ "blessed": "^0.1.81",
34
+ "boxen": "^8.0.1",
35
+ "chalk": "^5.4.1",
36
+ "gradient-string": "^3.0.0",
37
+ "ora": "^8.2.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/duyxyz/saca.git"
45
+ },
46
+ "homepage": "https://github.com/duyxyz/saca#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/duyxyz/saca/issues"
49
+ }
50
+ }