komado 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -41,7 +41,8 @@ with [Ink](https://github.com/vadimdemedes/ink).
41
41
 
42
42
  ## Install
43
43
 
44
- One line — fetches the latest, builds it, and drops a `komado` launcher on your PATH:
44
+ **macOS / Linux** one line fetches the latest, builds it, and drops a `komado`
45
+ launcher on your PATH:
45
46
 
46
47
  ```bash
47
48
  curl -fsSL https://raw.githubusercontent.com/RyuPrad/komado/main/install.sh | bash
@@ -51,8 +52,26 @@ Then just type **`komado`**. Re-run that same command any time to update. It nee
51
52
  `git`, **Node ≥ 20**, and `npm`; it installs to `~/.local/share/komado` with the
52
53
  launcher in `~/.local/bin` (override via `KOMADO_APP_DIR` / `KOMADO_BIN_DIR`).
53
54
 
54
- Uninstall: `rm -rf ~/.local/share/komado ~/.local/bin/komado` (add `~/.komado` to also
55
- drop your saved progress and login).
55
+ **Windows** the PowerShell one-liner installs Node.js for you (via `winget`) if it's
56
+ missing, then komado, leaving a native `komado` command on your `PATH`:
57
+
58
+ ```powershell
59
+ irm https://raw.githubusercontent.com/RyuPrad/komado/main/install.ps1 | iex
60
+ ```
61
+
62
+ Already have **Node ≥ 20**? Install straight from PowerShell or CMD instead:
63
+
64
+ ```powershell
65
+ npm i -g komado
66
+ ```
67
+
68
+ Don't use the `curl … | bash` line on Windows — under **Git Bash** or **WSL** it
69
+ installs a launcher that only runs inside that shell, never from CMD/PowerShell.
70
+
71
+ Uninstall from inside the app — **Settings → Uninstall komado…** (type `uninstall` to
72
+ confirm) — removes the app, its launcher, and `~/.komado` (config, reading progress,
73
+ MangaDex login). By hand that's
74
+ `rm -rf ~/.local/share/komado ~/.local/bin/komado ~/.komado`.
56
75
 
57
76
  ### From source (for development)
58
77
 
@@ -97,7 +116,7 @@ Readable text needs real pixels. At startup the app probes your terminal (run
97
116
  - **sixel or kitty graphics supported** → opening a chapter launches a
98
117
  full-resolution **pixel viewer** (chafa straight to the terminal). It renders
99
118
  full-width with vertical pan by default; press `f` to toggle whole-page fit.
100
- Keys: `←`/`→` or `a`/`d` page · `↑`/`↓` pan · `N`/`P` chapter · `f` fit · `q` back.
119
+ Keys: `←`/`→` or `a`/`d` page · `↑`/`↓` pan · `n`/`p` chapter · `f` fit · `q` back.
101
120
  - **neither** → the in-Ink **cell reader** is used (Unicode half-blocks, or
102
121
  chafa symbols when available). Fine for art, coarse for small lettering —
103
122
  that's the hard ceiling of character-cell rendering.
@@ -155,12 +155,12 @@ function ReaderScreen({ params }) {
155
155
  if (scroll >= maxScroll) nextPage();
156
156
  else setScroll((s) => Math.min(maxScroll, s + viewportRows - 1));
157
157
  } else if (key.pageUp) setScroll((s) => Math.max(0, s - (viewportRows - 1)));
158
- else if (key.rightArrow || input === "l" || input === "n") nextPage();
159
- else if (key.leftArrow || input === "h" || input === "p") prevPage();
158
+ else if (key.rightArrow || input === "l") nextPage();
159
+ else if (key.leftArrow || input === "h") prevPage();
160
160
  else if (input === "g") setScroll(0);
161
161
  else if (input === "G") setScroll(maxScroll);
162
- else if (input === "N") changeChapter(1);
163
- else if (input === "P") changeChapter(-1);
162
+ else if (input === "n" || input === "N") changeChapter(1);
163
+ else if (input === "p" || input === "P") changeChapter(-1);
164
164
  else if (input === "f") setFitMode((f) => !f);
165
165
  else if (input === "r") cycleRenderer();
166
166
  });
@@ -173,7 +173,7 @@ function ReaderScreen({ params }) {
173
173
  ] }),
174
174
  /* @__PURE__ */ jsx(Box, { height: viewportRows, flexDirection: "column", children: status === "loading" ? /* @__PURE__ */ jsx(Spinner, { label: pages ? `Rendering page ${pageIndex + 1}` : "Loading chapter" }) : status === "error" ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
175
175
  /* @__PURE__ */ jsx(ErrorView, { error }),
176
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press N / P to skip to another chapter, or Esc to go back." })
176
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press n / p to skip to another chapter, or Esc to go back." })
177
177
  ] }) : slice.map((ln, i) => /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", children: ln }, scroll + i)) }),
178
178
  /* @__PURE__ */ jsx(
179
179
  KeyHints,
@@ -181,7 +181,7 @@ function ReaderScreen({ params }) {
181
181
  hints: [
182
182
  ["\u2190\u2192", "page"],
183
183
  ["\u2191\u2193", "scroll"],
184
- ["N/P", "chapter"],
184
+ ["n/p", "chapter"],
185
185
  ["f", fitMode ? "scroll" : "fit"],
186
186
  ["r", `render:${rendererPref}`],
187
187
  ["esc", "back"]
@@ -10,6 +10,7 @@ import { detectCapabilities } from "../../render/detect.js";
10
10
  import { List } from "../List.js";
11
11
  import { Header, KeyHints } from "../ui.js";
12
12
  import { truncate } from "../../lib/text.js";
13
+ import { performUninstall, uninstallTargets, displayPath, formatUninstallSummary } from "../../uninstall.js";
13
14
  const RENDERERS = ["auto", "halfblock", "chafa"];
14
15
  const RATING_PRESETS = [
15
16
  ["safe"],
@@ -52,7 +53,8 @@ function SettingsScreen() {
52
53
  pathIndex: i,
53
54
  label: `Library: ${truncate(p, 48)}`,
54
55
  value: "d to remove"
55
- }))
56
+ })),
57
+ { id: "uninstall", kind: "danger", label: "Uninstall komado\u2026", value: "removes the app + all data" }
56
58
  ];
57
59
  const activate = (item) => {
58
60
  switch (item.kind) {
@@ -78,11 +80,27 @@ function SettingsScreen() {
78
80
  case "action":
79
81
  setDraft("");
80
82
  return setEditing("addPath");
83
+ case "danger":
84
+ setDraft("");
85
+ return setEditing("uninstall");
81
86
  default:
82
87
  return void 0;
83
88
  }
84
89
  };
85
90
  const submitEdit = () => {
91
+ if (editing === "uninstall") {
92
+ if (draft.trim().toLowerCase() === "uninstall") {
93
+ const results = performUninstall();
94
+ process.once("exit", () => process.stdout.write(`
95
+ ${formatUninstallSummary(results)}
96
+ `));
97
+ ui.exit();
98
+ return;
99
+ }
100
+ setEditing(null);
101
+ setDraft("");
102
+ return;
103
+ }
86
104
  if (editing === "language") {
87
105
  save({ language: draft.trim() || "en" });
88
106
  } else if (editing === "addPath" && draft.trim()) {
@@ -113,7 +131,21 @@ function SettingsScreen() {
113
131
  subtitle: `chafa: ${caps.chafa ? caps.chafaVersion : "not installed"} \xB7 backend: ${caps.chafa ? "chafa-symbols" : "half-block"}`
114
132
  }
115
133
  ),
116
- editing ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
134
+ editing === "uninstall" ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
135
+ /* @__PURE__ */ jsx(Text, { color: "redBright", bold: true, children: "\u26A0 Uninstall komado" }),
136
+ /* @__PURE__ */ jsx(Text, { children: "This permanently deletes:" }),
137
+ uninstallTargets().map((t) => /* @__PURE__ */ jsxs(Text, { color: "red", children: [
138
+ ` \u2022 ${displayPath(t.path)}`,
139
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \u2014 ${t.label}` })
140
+ ] }, t.path)),
141
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
142
+ /* @__PURE__ */ jsx(Text, { children: "Type " }),
143
+ /* @__PURE__ */ jsx(Text, { color: "redBright", bold: true, children: "uninstall" }),
144
+ /* @__PURE__ */ jsx(Text, { children: " to confirm: " }),
145
+ /* @__PURE__ */ jsx(TextInput, { value: draft, onChange: setDraft, onSubmit: submitEdit, focus: true })
146
+ ] }),
147
+ /* @__PURE__ */ jsx(KeyHints, { hints: [["enter", "confirm"], ["esc", "cancel"]] })
148
+ ] }) : editing ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
117
149
  /* @__PURE__ */ jsx(Text, { color: "cyanBright", children: editing === "addPath" ? "New library path (folder of manga / CBZ):" : "Language code (e.g. en, fr, ja):" }),
118
150
  /* @__PURE__ */ jsxs(Box, { children: [
119
151
  /* @__PURE__ */ jsx(Text, { color: "cyanBright", children: "\u203A " }),
@@ -130,7 +162,7 @@ function SettingsScreen() {
130
162
  onSelect: activate,
131
163
  onHighlight: (it) => setHighlighted(it),
132
164
  renderItem: (it, active) => /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
133
- /* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : it.kind === "path" ? "blue" : void 0, children: ` ${it.label} ` }),
165
+ /* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : it.kind === "danger" ? "red" : it.kind === "path" ? "blue" : void 0, children: ` ${it.label} ` }),
134
166
  it.value ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: it.value }) : null
135
167
  ] }, it.id)
136
168
  }
@@ -68,7 +68,7 @@ async function runViewer({ sourceId, manga, chapters, chapterIndex, startPage =
68
68
  function statusBar() {
69
69
  const { cols } = size();
70
70
  const left = `${manga.title} \xB7 ${chapterLabel(chapters[ci])} \xB7 ${pi + 1}/${pages ? pages.length : "?"}${fitWidth ? "" : " \xB7 fit"}`;
71
- const right = "\u2190\u2192 page \xB7 \u2191\u2193 pan \xB7 N/P ch \xB7 f fit \xB7 q back";
71
+ const right = "\u2190\u2192 page \xB7 \u2191\u2193 pan \xB7 n/p ch \xB7 f fit \xB7 q back";
72
72
  const gap = Math.max(1, cols - left.length - right.length - 2);
73
73
  return ` ${left}${" ".repeat(gap)}${right} `.slice(0, cols);
74
74
  }
@@ -152,7 +152,7 @@ async function runViewer({ sourceId, manga, chapters, chapterIndex, startPage =
152
152
  chapterNumber: chapters[ci].number,
153
153
  page: pi
154
154
  });
155
- if (pi === pages.length - 1 && source.syncChapterRead) {
155
+ if (pages && pi === pages.length - 1 && source.syncChapterRead) {
156
156
  source.syncChapterRead(manga.id, chapters[ci].id);
157
157
  }
158
158
  } catch (err) {
@@ -160,7 +160,7 @@ async function runViewer({ sourceId, manga, chapters, chapterIndex, startPage =
160
160
  stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
161
161
  stdout.write(`Error: ${err.message}\r
162
162
  \r
163
- N/P chapter \xB7 q back\r
163
+ n/p chapter \xB7 q back\r
164
164
  `);
165
165
  }
166
166
  }
@@ -250,12 +250,9 @@ N/P chapter \xB7 q back\r
250
250
  resolve();
251
251
  return;
252
252
  }
253
- if (k === "n" || k === " ") {
253
+ if (k === " ") {
254
254
  if (fitWidth && scroll < maxScroll) scroll = Math.min(maxScroll, scroll + pageStep);
255
255
  else nextPage();
256
- } else if (k === "p") {
257
- if (fitWidth && scroll > 0) scroll = Math.max(0, scroll - pageStep);
258
- else prevPage();
259
256
  } else if (k === "d" || k === `${ESC}[C`) {
260
257
  nextPage();
261
258
  } else if (k === "a" || k === `${ESC}[D`) {
@@ -264,9 +261,9 @@ N/P chapter \xB7 q back\r
264
261
  scroll = Math.min(maxScroll, scroll + scrollStep);
265
262
  } else if (k === "k" || k === `${ESC}[A`) {
266
263
  scroll = Math.max(0, scroll - scrollStep);
267
- } else if (k === "N") {
264
+ } else if (k === "n" || k === "N") {
268
265
  changeChapter(1);
269
- } else if (k === "P") {
266
+ } else if (k === "p" || k === "P") {
270
267
  changeChapter(-1);
271
268
  } else if (k === "f") {
272
269
  fitWidth = !fitWidth;
@@ -9,7 +9,13 @@ function readJson(file, fallback) {
9
9
  return fallback;
10
10
  }
11
11
  }
12
+ let persistenceDisabled = false;
13
+ function disablePersistence() {
14
+ persistenceDisabled = true;
15
+ clearTimeout(saveTimer);
16
+ }
12
17
  function writeJsonAtomic(file, data) {
18
+ if (persistenceDisabled) return;
13
19
  ensureDirs();
14
20
  const tmp = `${file}.${process.pid}.tmp`;
15
21
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
@@ -32,6 +38,7 @@ function loadProgress() {
32
38
  }
33
39
  let saveTimer = null;
34
40
  function scheduleSave() {
41
+ if (persistenceDisabled) return;
35
42
  clearTimeout(saveTimer);
36
43
  saveTimer = setTimeout(() => writeJsonAtomic(paths.progressFile, progress), 400);
37
44
  saveTimer.unref?.();
@@ -52,6 +59,7 @@ function flushProgress() {
52
59
  }
53
60
  let credentials = null;
54
61
  function writeCredentialsAtomic(data) {
62
+ if (persistenceDisabled) return;
55
63
  ensureDirs();
56
64
  const file = paths.credentialsFile;
57
65
  const tmp = `${file}.${process.pid}.tmp`;
@@ -80,6 +88,7 @@ function clearCredentials() {
80
88
  }
81
89
  export {
82
90
  clearCredentials,
91
+ disablePersistence,
83
92
  flushProgress,
84
93
  getAllProgress,
85
94
  getConfig,
@@ -0,0 +1,58 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { paths } from "./config.js";
5
+ import { disablePersistence } from "./state/store.js";
6
+ function appDir() {
7
+ if (process.env.KOMADO_APP_DIR) return path.resolve(process.env.KOMADO_APP_DIR);
8
+ const share = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
9
+ return path.join(share, "komado");
10
+ }
11
+ function launcherFile() {
12
+ const bin = process.env.KOMADO_BIN_DIR ? path.resolve(process.env.KOMADO_BIN_DIR) : path.join(os.homedir(), ".local", "bin");
13
+ return path.join(bin, "komado");
14
+ }
15
+ function uninstallTargets() {
16
+ return [
17
+ { label: "application files", path: appDir() },
18
+ { label: "launcher", path: launcherFile() },
19
+ { label: "config, reading progress & MangaDex login", path: paths.home }
20
+ ];
21
+ }
22
+ function displayPath(p) {
23
+ const home = os.homedir();
24
+ if (p === home) return "~";
25
+ return p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
26
+ }
27
+ function assertSafe(p) {
28
+ const resolved = path.resolve(p);
29
+ if (!resolved || resolved === path.parse(resolved).root || resolved === os.homedir()) {
30
+ throw new Error(`refusing to delete unsafe path: ${resolved}`);
31
+ }
32
+ }
33
+ function performUninstall() {
34
+ disablePersistence();
35
+ return uninstallTargets().map((t) => {
36
+ try {
37
+ assertSafe(t.path);
38
+ const existed = fs.existsSync(t.path);
39
+ fs.rmSync(t.path, { recursive: true, force: true });
40
+ return { ...t, status: existed ? "removed" : "absent" };
41
+ } catch (err) {
42
+ return { ...t, status: "failed", error: err.message };
43
+ }
44
+ });
45
+ }
46
+ function formatUninstallSummary(results) {
47
+ const ok = results.every((r) => r.status !== "failed");
48
+ const tag = { removed: "removed", absent: "absent ", failed: "FAILED " };
49
+ const lines = results.map((r) => ` ${tag[r.status]} ${displayPath(r.path)}${r.error ? ` (${r.error})` : ""}`);
50
+ const head = ok ? "komado has been uninstalled." : "komado uninstall finished with errors.";
51
+ return [head, "", ...lines, ""].join("\n");
52
+ }
53
+ export {
54
+ displayPath,
55
+ formatUninstallSummary,
56
+ performUninstall,
57
+ uninstallTargets
58
+ };
package/package.json CHANGED
@@ -1,8 +1,18 @@
1
1
  {
2
2
  "name": "komado",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A terminal manga reader (MangaDex + local files) rendered with Ink.",
5
- "keywords": ["manga", "mangadex", "reader", "terminal", "tui", "cli", "sixel", "cbz", "comic"],
5
+ "keywords": [
6
+ "manga",
7
+ "mangadex",
8
+ "reader",
9
+ "terminal",
10
+ "tui",
11
+ "cli",
12
+ "sixel",
13
+ "cbz",
14
+ "comic"
15
+ ],
6
16
  "type": "module",
7
17
  "bin": {
8
18
  "komado": "dist/cli.js"
@@ -26,7 +36,10 @@
26
36
  "node": ">=20"
27
37
  },
28
38
  "license": "MIT",
29
- "repository": { "type": "git", "url": "git+https://github.com/RyuPrad/komado.git" },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/RyuPrad/komado.git"
42
+ },
30
43
  "homepage": "https://github.com/RyuPrad/komado#readme",
31
44
  "bugs": "https://github.com/RyuPrad/komado/issues",
32
45
  "dependencies": {
@@ -179,12 +179,12 @@ export function ReaderScreen({ params }) {
179
179
  if (scroll >= maxScroll) nextPage();
180
180
  else setScroll((s) => Math.min(maxScroll, s + viewportRows - 1));
181
181
  } else if (key.pageUp) setScroll((s) => Math.max(0, s - (viewportRows - 1)));
182
- else if (key.rightArrow || input === 'l' || input === 'n') nextPage();
183
- else if (key.leftArrow || input === 'h' || input === 'p') prevPage();
182
+ else if (key.rightArrow || input === 'l') nextPage();
183
+ else if (key.leftArrow || input === 'h') prevPage();
184
184
  else if (input === 'g') setScroll(0);
185
185
  else if (input === 'G') setScroll(maxScroll);
186
- else if (input === 'N') changeChapter(1);
187
- else if (input === 'P') changeChapter(-1);
186
+ else if (input === 'n' || input === 'N') changeChapter(1);
187
+ else if (input === 'p' || input === 'P') changeChapter(-1);
188
188
  else if (input === 'f') setFitMode((f) => !f);
189
189
  else if (input === 'r') cycleRenderer();
190
190
  });
@@ -206,7 +206,7 @@ export function ReaderScreen({ params }) {
206
206
  ) : status === 'error' ? (
207
207
  <Box flexDirection="column">
208
208
  <ErrorView error={error} />
209
- <Text dimColor>Press N / P to skip to another chapter, or Esc to go back.</Text>
209
+ <Text dimColor>Press n / p to skip to another chapter, or Esc to go back.</Text>
210
210
  </Box>
211
211
  ) : (
212
212
  slice.map((ln, i) => (
@@ -219,7 +219,7 @@ export function ReaderScreen({ params }) {
219
219
  hints={[
220
220
  ['←→', 'page'],
221
221
  ['↑↓', 'scroll'],
222
- ['N/P', 'chapter'],
222
+ ['n/p', 'chapter'],
223
223
  ['f', fitMode ? 'scroll' : 'fit'],
224
224
  ['r', `render:${rendererPref}`],
225
225
  ['esc', 'back'],
@@ -9,6 +9,7 @@ import { detectCapabilities } from '../../render/detect.js';
9
9
  import { List } from '../List.js';
10
10
  import { Header, KeyHints } from '../ui.js';
11
11
  import { truncate } from '../../lib/text.js';
12
+ import { performUninstall, uninstallTargets, displayPath, formatUninstallSummary } from '../../uninstall.js';
12
13
 
13
14
  const RENDERERS = ['auto', 'halfblock', 'chafa'];
14
15
  const RATING_PRESETS = [
@@ -50,6 +51,7 @@ export function SettingsScreen() {
50
51
  ...cfg.localLibraryPaths.map((p, i) => ({
51
52
  id: `path:${i}`, kind: 'path', pathIndex: i, label: `Library: ${truncate(p, 48)}`, value: 'd to remove',
52
53
  })),
54
+ { id: 'uninstall', kind: 'danger', label: 'Uninstall komado…', value: 'removes the app + all data' },
53
55
  ];
54
56
 
55
57
  const activate = (item) => {
@@ -73,12 +75,29 @@ export function SettingsScreen() {
73
75
  case 'action':
74
76
  setDraft('');
75
77
  return setEditing('addPath');
78
+ case 'danger':
79
+ setDraft('');
80
+ return setEditing('uninstall');
76
81
  default:
77
82
  return undefined;
78
83
  }
79
84
  };
80
85
 
81
86
  const submitEdit = () => {
87
+ if (editing === 'uninstall') {
88
+ // Only the exact word commits. performUninstall() disables persistence and
89
+ // deletes everything; we register a goodbye to print AFTER Ink tears the
90
+ // alt-screen down (cli.js's restore runs first), then quit.
91
+ if (draft.trim().toLowerCase() === 'uninstall') {
92
+ const results = performUninstall();
93
+ process.once('exit', () => process.stdout.write(`\n${formatUninstallSummary(results)}\n`));
94
+ ui.exit();
95
+ return;
96
+ }
97
+ setEditing(null);
98
+ setDraft('');
99
+ return;
100
+ }
82
101
  if (editing === 'language') {
83
102
  save({ language: draft.trim() || 'en' });
84
103
  } else if (editing === 'addPath' && draft.trim()) {
@@ -110,7 +129,25 @@ export function SettingsScreen() {
110
129
  subtitle={`chafa: ${caps.chafa ? caps.chafaVersion : 'not installed'} · backend: ${caps.chafa ? 'chafa-symbols' : 'half-block'}`}
111
130
  />
112
131
 
113
- {editing ? (
132
+ {editing === 'uninstall' ? (
133
+ <Box flexDirection="column">
134
+ <Text color="redBright" bold>{'⚠ Uninstall komado'}</Text>
135
+ <Text>This permanently deletes:</Text>
136
+ {uninstallTargets().map((t) => (
137
+ <Text key={t.path} color="red">
138
+ {` • ${displayPath(t.path)}`}
139
+ <Text dimColor>{` — ${t.label}`}</Text>
140
+ </Text>
141
+ ))}
142
+ <Box marginTop={1}>
143
+ <Text>{'Type '}</Text>
144
+ <Text color="redBright" bold>uninstall</Text>
145
+ <Text>{' to confirm: '}</Text>
146
+ <TextInput value={draft} onChange={setDraft} onSubmit={submitEdit} focus={true} />
147
+ </Box>
148
+ <KeyHints hints={[['enter', 'confirm'], ['esc', 'cancel']]} />
149
+ </Box>
150
+ ) : editing ? (
114
151
  <Box flexDirection="column">
115
152
  <Text color="cyanBright">
116
153
  {editing === 'addPath' ? 'New library path (folder of manga / CBZ):' : 'Language code (e.g. en, fr, ja):'}
@@ -131,7 +168,7 @@ export function SettingsScreen() {
131
168
  onHighlight={(it) => setHighlighted(it)}
132
169
  renderItem={(it, active) => (
133
170
  <Box key={it.id} justifyContent="space-between">
134
- <Text inverse={active} color={active ? 'cyanBright' : it.kind === 'path' ? 'blue' : undefined}>
171
+ <Text inverse={active} color={active ? 'cyanBright' : it.kind === 'danger' ? 'red' : it.kind === 'path' ? 'blue' : undefined}>
135
172
  {` ${it.label} `}
136
173
  </Text>
137
174
  {it.value ? <Text dimColor>{it.value}</Text> : null}
@@ -106,7 +106,7 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
106
106
  function statusBar() {
107
107
  const { cols } = size();
108
108
  const left = `${manga.title} · ${chapterLabel(chapters[ci])} · ${pi + 1}/${pages ? pages.length : '?'}${fitWidth ? '' : ' · fit'}`;
109
- const right = '←→ page · ↑↓ pan · N/P ch · f fit · q back';
109
+ const right = '←→ page · ↑↓ pan · n/p ch · f fit · q back';
110
110
  const gap = Math.max(1, cols - left.length - right.length - 2);
111
111
  return ` ${left}${' '.repeat(gap)}${right} `.slice(0, cols);
112
112
  }
@@ -199,13 +199,16 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
199
199
  page: pi,
200
200
  });
201
201
  // Last page reached → push a read-marker to MangaDex (self-guarded/deduped).
202
- if (pi === pages.length - 1 && source.syncChapterRead) {
202
+ // `pages &&`: a rapid n/p runs changeChapter() (pages = null) during one of
203
+ // this draw's awaits, after it started — guard before reading .length, or the
204
+ // stale tail throws "Cannot read properties of null (reading 'length')".
205
+ if (pages && pi === pages.length - 1 && source.syncChapterRead) {
203
206
  source.syncChapterRead(manga.id, chapters[ci].id);
204
207
  }
205
208
  } catch (err) {
206
209
  logger.warn('viewer draw failed', err);
207
210
  stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
208
- stdout.write(`Error: ${err.message}\r\n\r\nN/P chapter · q back\r\n`);
211
+ stdout.write(`Error: ${err.message}\r\n\r\nn/p chapter · q back\r\n`);
209
212
  }
210
213
  }
211
214
 
@@ -303,12 +306,10 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
303
306
  const pageStep = Math.max(1, size().rows - 2);
304
307
 
305
308
  if (k === 'q' || k === ESC) { resolve(); return; }
306
- if (k === 'n' || k === ' ') {
309
+ if (k === ' ') {
310
+ // space = read-through: scroll a full page, then advance at the bottom
307
311
  if (fitWidth && scroll < maxScroll) scroll = Math.min(maxScroll, scroll + pageStep);
308
312
  else nextPage();
309
- } else if (k === 'p') {
310
- if (fitWidth && scroll > 0) scroll = Math.max(0, scroll - pageStep);
311
- else prevPage();
312
313
  } else if (k === 'd' || k === `${ESC}[C`) {
313
314
  nextPage(); // → / d : next page
314
315
  } else if (k === 'a' || k === `${ESC}[D`) {
@@ -317,10 +318,10 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
317
318
  scroll = Math.min(maxScroll, scroll + scrollStep);
318
319
  } else if (k === 'k' || k === `${ESC}[A`) {
319
320
  scroll = Math.max(0, scroll - scrollStep);
320
- } else if (k === 'N') {
321
- changeChapter(1);
322
- } else if (k === 'P') {
323
- changeChapter(-1);
321
+ } else if (k === 'n' || k === 'N') {
322
+ changeChapter(1); // n / N : next chapter
323
+ } else if (k === 'p' || k === 'P') {
324
+ changeChapter(-1); // p / P : previous chapter
324
325
  } else if (k === 'f') {
325
326
  fitWidth = !fitWidth;
326
327
  scroll = 0;
@@ -11,8 +11,18 @@ function readJson(file, fallback) {
11
11
  }
12
12
  }
13
13
 
14
+ // While the app is uninstalling itself, every writer no-ops so a pending debounced
15
+ // save (or the flushProgress() that quit() runs) can't recreate the data directory
16
+ // we just deleted. Set once, never unset — the process is on its way out.
17
+ let persistenceDisabled = false;
18
+ export function disablePersistence() {
19
+ persistenceDisabled = true;
20
+ clearTimeout(saveTimer);
21
+ }
22
+
14
23
  // Write-to-temp + rename so a crash mid-write never corrupts the file.
15
24
  function writeJsonAtomic(file, data) {
25
+ if (persistenceDisabled) return;
16
26
  ensureDirs();
17
27
  const tmp = `${file}.${process.pid}.tmp`;
18
28
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
@@ -42,6 +52,7 @@ function loadProgress() {
42
52
  // Debounced save — the reader updates progress on every page turn, so coalesce.
43
53
  let saveTimer = null;
44
54
  function scheduleSave() {
55
+ if (persistenceDisabled) return;
45
56
  clearTimeout(saveTimer);
46
57
  saveTimer = setTimeout(() => writeJsonAtomic(paths.progressFile, progress), 400);
47
58
  saveTimer.unref?.();
@@ -68,6 +79,7 @@ export function flushProgress() {
68
79
  // never written to disk. File is 0600 since it holds a long-lived secret.
69
80
  let credentials = null;
70
81
  function writeCredentialsAtomic(data) {
82
+ if (persistenceDisabled) return;
71
83
  ensureDirs();
72
84
  const file = paths.credentialsFile;
73
85
  const tmp = `${file}.${process.pid}.tmp`;
@@ -0,0 +1,75 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { paths } from './config.js';
5
+ import { disablePersistence } from './state/store.js';
6
+
7
+ // Where install.sh put things. Mirrors the installer's own defaults + env
8
+ // overrides (KOMADO_APP_DIR / KOMADO_BIN_DIR) so an in-app uninstall targets
9
+ // exactly what was installed — and never a dev checkout, which lives elsewhere.
10
+ function appDir() {
11
+ if (process.env.KOMADO_APP_DIR) return path.resolve(process.env.KOMADO_APP_DIR);
12
+ const share = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
13
+ return path.join(share, 'komado');
14
+ }
15
+ function launcherFile() {
16
+ const bin = process.env.KOMADO_BIN_DIR
17
+ ? path.resolve(process.env.KOMADO_BIN_DIR)
18
+ : path.join(os.homedir(), '.local', 'bin');
19
+ // The launcher FILE only — never the whole bin dir, which is shared with other tools.
20
+ return path.join(bin, 'komado');
21
+ }
22
+
23
+ // The three things a full install creates. `paths.home` is the runtime data dir
24
+ // (~/.komado or KOMADO_HOME) and holds config, reading progress, the MangaDex
25
+ // login, the page cache, and the log — all under one dir, so one delete clears it.
26
+ export function uninstallTargets() {
27
+ return [
28
+ { label: 'application files', path: appDir() },
29
+ { label: 'launcher', path: launcherFile() },
30
+ { label: 'config, reading progress & MangaDex login', path: paths.home },
31
+ ];
32
+ }
33
+
34
+ // $HOME → ~ for display.
35
+ export function displayPath(p) {
36
+ const home = os.homedir();
37
+ if (p === home) return '~';
38
+ return p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
39
+ }
40
+
41
+ // Defensive: a recursive force-delete must never escape to a filesystem or home
42
+ // root, however the paths above were derived.
43
+ function assertSafe(p) {
44
+ const resolved = path.resolve(p);
45
+ if (!resolved || resolved === path.parse(resolved).root || resolved === os.homedir()) {
46
+ throw new Error(`refusing to delete unsafe path: ${resolved}`);
47
+ }
48
+ }
49
+
50
+ // Remove the installed app, its launcher, and all runtime data. Synchronous — a
51
+ // one-shot teardown run right before exit. It first switches off the store's
52
+ // persistence so a pending debounced save (or the flushProgress() that quit()
53
+ // runs) can't recreate the data dir we're deleting. One failing target never
54
+ // aborts the rest; returns a per-target status list for the goodbye summary.
55
+ export function performUninstall() {
56
+ disablePersistence();
57
+ return uninstallTargets().map((t) => {
58
+ try {
59
+ assertSafe(t.path);
60
+ const existed = fs.existsSync(t.path);
61
+ fs.rmSync(t.path, { recursive: true, force: true });
62
+ return { ...t, status: existed ? 'removed' : 'absent' };
63
+ } catch (err) {
64
+ return { ...t, status: 'failed', error: err.message };
65
+ }
66
+ });
67
+ }
68
+
69
+ export function formatUninstallSummary(results) {
70
+ const ok = results.every((r) => r.status !== 'failed');
71
+ const tag = { removed: 'removed', absent: 'absent ', failed: 'FAILED ' };
72
+ const lines = results.map((r) => ` ${tag[r.status]} ${displayPath(r.path)}${r.error ? ` (${r.error})` : ''}`);
73
+ const head = ok ? 'komado has been uninstalled.' : 'komado uninstall finished with errors.';
74
+ return [head, '', ...lines, ''].join('\n');
75
+ }