open-nami 1.3.0 → 1.5.1

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 (2) hide show
  1. package/dist/cli.js +1115 -358
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ var require_package = __commonJS({
9
9
  "package.json"(exports, module) {
10
10
  module.exports = {
11
11
  name: "open-nami",
12
- version: "1.3.0",
12
+ version: "1.5.1",
13
13
  description: "Open Nami: terminal-first bulk social media profile downloader for Instagram, TikTok, Facebook & X.",
14
14
  type: "module",
15
15
  bin: {
@@ -73,11 +73,11 @@ var require_package = __commonJS({
73
73
  });
74
74
 
75
75
  // src/cli.tsx
76
- import { createRequire } from "module";
76
+ import { createRequire as createRequire2 } from "module";
77
77
  import { render } from "ink";
78
78
 
79
79
  // src/app.tsx
80
- import { useState as useState9, useCallback as useCallback4, useRef as useRef5, useEffect as useEffect8, useMemo as useMemo3 } from "react";
80
+ import { useState as useState10, useCallback as useCallback4, useRef as useRef5, useEffect as useEffect10, useMemo as useMemo3 } from "react";
81
81
  import { Box as Box15, Text as Text17, useApp, useInput as useInput5, useStdout as useStdout3 } from "ink";
82
82
  import Spinner4 from "ink-spinner";
83
83
 
@@ -730,16 +730,8 @@ async function clearCheckpoint(destDir) {
730
730
  }
731
731
 
732
732
  // src/lib/engines/dispatcher.ts
733
- import path10 from "path";
734
- import os5 from "os";
735
- import fs10 from "fs/promises";
736
- import fsSync from "fs";
737
-
738
- // src/lib/cookies.ts
739
- import fs2 from "fs";
740
- import fsPromises2 from "fs/promises";
741
- import path3 from "path";
742
- import os2 from "os";
733
+ import path13 from "path";
734
+ import fs13 from "fs/promises";
743
735
 
744
736
  // src/lib/config.ts
745
737
  import fs from "fs";
@@ -757,9 +749,13 @@ function loadConfig(configPath = getDefaultConfigPath()) {
757
749
  const raw = fs.readFileSync(configPath, "utf8");
758
750
  const parsed = JSON.parse(raw);
759
751
  if (parsed && typeof parsed === "object") {
752
+ const rawBrowser = typeof parsed.cookiesFromBrowser === "string" ? parsed.cookiesFromBrowser.trim().toLowerCase() : void 0;
753
+ const validBrowsers = ["chrome", "edge", "brave", "firefox", "none"];
754
+ const cookiesFromBrowser = rawBrowser && validBrowsers.includes(rawBrowser) ? rawBrowser : void 0;
760
755
  return {
761
756
  theme: typeof parsed.theme === "string" ? parsed.theme : void 0,
762
757
  cookiesDir: typeof parsed.cookiesDir === "string" ? parsed.cookiesDir.trim() : void 0,
758
+ cookiesFromBrowser,
763
759
  downloadDir: typeof parsed.downloadDir === "string" ? parsed.downloadDir.trim() : void 0,
764
760
  writeMetadata: typeof parsed.writeMetadata === "boolean" ? parsed.writeMetadata : void 0,
765
761
  defaultLimit: typeof parsed.defaultLimit === "number" ? parsed.defaultLimit : void 0
@@ -781,22 +777,77 @@ function saveConfig(config, configPath = getDefaultConfigPath()) {
781
777
  }
782
778
  }
783
779
 
780
+ // src/lib/cookies.ts
781
+ import fs3 from "fs";
782
+ import fsPromises2 from "fs/promises";
783
+ import path4 from "path";
784
+ import os3 from "os";
785
+
786
+ // src/lib/cookies/browser-bridge.ts
787
+ import fs2 from "fs";
788
+ import path3 from "path";
789
+ import os2 from "os";
790
+ function listDetectedBrowsers() {
791
+ const isWin = process.platform === "win32";
792
+ const isMac = process.platform === "darwin";
793
+ const home = os2.homedir();
794
+ const candidates = [
795
+ {
796
+ id: "chrome",
797
+ name: "Google Chrome",
798
+ winDir: path3.join(process.env.LOCALAPPDATA || path3.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
799
+ macDir: path3.join(home, "Library", "Application Support", "Google", "Chrome"),
800
+ linuxDir: path3.join(home, ".config", "google-chrome")
801
+ },
802
+ {
803
+ id: "edge",
804
+ name: "Microsoft Edge",
805
+ winDir: path3.join(process.env.LOCALAPPDATA || path3.join(home, "AppData", "Local"), "Microsoft", "Edge", "User Data"),
806
+ macDir: path3.join(home, "Library", "Application Support", "Microsoft Edge"),
807
+ linuxDir: path3.join(home, ".config", "microsoft-edge")
808
+ },
809
+ {
810
+ id: "brave",
811
+ name: "Brave Browser",
812
+ winDir: path3.join(process.env.LOCALAPPDATA || path3.join(home, "AppData", "Local"), "BraveSoftware", "Brave-Browser", "User Data"),
813
+ macDir: path3.join(home, "Library", "Application Support", "BraveSoftware", "Brave-Browser"),
814
+ linuxDir: path3.join(home, ".config", "BraveSoftware", "Brave-Browser")
815
+ },
816
+ {
817
+ id: "firefox",
818
+ name: "Mozilla Firefox",
819
+ winDir: path3.join(process.env.APPDATA || path3.join(home, "AppData", "Roaming"), "Mozilla", "Firefox", "Profiles"),
820
+ macDir: path3.join(home, "Library", "Application Support", "Firefox", "Profiles"),
821
+ linuxDir: path3.join(home, ".mozilla", "firefox")
822
+ }
823
+ ];
824
+ return candidates.map((c) => {
825
+ const profileDir = isWin ? c.winDir : isMac ? c.macDir : c.linuxDir;
826
+ return {
827
+ id: c.id,
828
+ name: c.name,
829
+ profileDir,
830
+ available: fs2.existsSync(profileDir)
831
+ };
832
+ });
833
+ }
834
+
784
835
  // src/lib/cookies.ts
785
836
  var CANDIDATE_DIRS = [
786
- path3.join(os2.homedir(), ".open-nami", "cookies"),
787
- path3.join(os2.homedir(), ".nami", "cookies"),
788
- path3.join(process.cwd(), "cookies")
837
+ path4.join(os3.homedir(), ".open-nami", "cookies"),
838
+ path4.join(os3.homedir(), ".nami", "cookies"),
839
+ path4.join(process.cwd(), "cookies")
789
840
  ];
790
841
  function getCandidateDirs(customDir) {
791
842
  const dirs = [];
792
843
  const configured = loadConfig().cookiesDir;
793
844
  const custom = customDir || process.env.OPEN_NAMI_COOKIES_DIR || process.env.NAMI_COOKIES_DIR || configured;
794
845
  if (custom && custom.trim()) {
795
- dirs.push(path3.resolve(custom.trim()));
846
+ dirs.push(path4.resolve(custom.trim()));
796
847
  }
797
- const seen = new Set(dirs.map((d) => path3.normalize(d).toLowerCase()));
848
+ const seen = new Set(dirs.map((d) => path4.normalize(d).toLowerCase()));
798
849
  for (const dir of CANDIDATE_DIRS) {
799
- const norm = path3.normalize(dir).toLowerCase();
850
+ const norm = path4.normalize(dir).toLowerCase();
800
851
  if (!seen.has(norm)) {
801
852
  seen.add(norm);
802
853
  dirs.push(dir);
@@ -816,12 +867,12 @@ function findCookieFile(platform, customDir) {
816
867
  const filenames = domainNames[platform] || [];
817
868
  const candidateDirs = getCandidateDirs(customDir);
818
869
  for (const dir of candidateDirs) {
819
- if (!fs2.existsSync(dir)) continue;
870
+ if (!fs3.existsSync(dir)) continue;
820
871
  for (const file of filenames) {
821
- const fullPath = path3.join(dir, file);
822
- if (fs2.existsSync(fullPath)) {
872
+ const fullPath = path4.join(dir, file);
873
+ if (fs3.existsSync(fullPath)) {
823
874
  try {
824
- const stats = fs2.statSync(fullPath);
875
+ const stats = fs3.statSync(fullPath);
825
876
  if (stats.size > 20) {
826
877
  return fullPath;
827
878
  }
@@ -837,14 +888,14 @@ function detectAvailableCookies(customDir) {
837
888
  return platforms.filter((p) => Boolean(findCookieFile(p, customDir)));
838
889
  }
839
890
  async function createSafeCookieCopy(masterPath) {
840
- const userTempDir = path3.join(os2.homedir(), ".open-nami", "temp");
891
+ const userTempDir = path4.join(os3.homedir(), ".open-nami", "temp");
841
892
  let targetDir = userTempDir;
842
893
  try {
843
894
  await fsPromises2.mkdir(userTempDir, { recursive: true, mode: 448 });
844
895
  } catch {
845
- targetDir = os2.tmpdir();
896
+ targetDir = os3.tmpdir();
846
897
  }
847
- const tempPath = path3.join(targetDir, `nami_cookie_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`);
898
+ const tempPath = path4.join(targetDir, `nami_cookie_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`);
848
899
  await fsPromises2.copyFile(masterPath, tempPath);
849
900
  await fsPromises2.chmod(tempPath, 384).catch(() => {
850
901
  });
@@ -860,141 +911,6 @@ async function createSafeCookieCopy(masterPath) {
860
911
  };
861
912
  }
862
913
 
863
- // src/lib/known-folders.ts
864
- import { execFileSync } from "child_process";
865
- import fs3 from "fs";
866
- import os3 from "os";
867
- import path4 from "path";
868
- var KNOWN_FOLDER_DOWNLOADS_GUID = "{374DE290-123F-4565-9164-39C4925E467B}";
869
- var USER_SHELL_FOLDERS_REG_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders";
870
- function expandWindowsEnv(str, env = process.env, homeDir) {
871
- return str.replace(/%([^%]+)%/g, (original, name) => {
872
- if (homeDir && name.toLowerCase() === "userprofile") {
873
- return homeDir;
874
- }
875
- const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
876
- if (key && env[key] !== void 0) {
877
- return env[key];
878
- }
879
- return original;
880
- });
881
- }
882
- function parseWindowsRegistryOutput(output, targetKey = KNOWN_FOLDER_DOWNLOADS_GUID) {
883
- const lines = output.split(/\r?\n/);
884
- const escaped = targetKey.replace(/[{}]/g, "\\$&");
885
- const pattern = new RegExp(`^\\s*${escaped}\\s+REG_\\w+\\s+(.*)$`, "i");
886
- for (const line of lines) {
887
- const match = line.match(pattern);
888
- if (match && match[1]) {
889
- const rawPath = match[1].trim();
890
- if (rawPath) return rawPath;
891
- }
892
- }
893
- return void 0;
894
- }
895
- function parseLinuxUserDirsOutput(content) {
896
- const lines = content.split(/\r?\n/);
897
- for (const line of lines) {
898
- const trimmed = line.trim();
899
- if (trimmed.startsWith("#")) continue;
900
- const match = trimmed.match(/^XDG_DOWNLOAD_DIR=["']?([^"']+)["']?$/);
901
- if (match && match[1]) {
902
- return match[1].trim();
903
- }
904
- }
905
- return void 0;
906
- }
907
- function isPathDriveAccessible(filePath, fsExists = fs3.existsSync, platform = process.platform) {
908
- try {
909
- if (fsExists(filePath)) {
910
- return true;
911
- }
912
- if (platform === "win32") {
913
- const match = filePath.match(/^([a-zA-Z]:[\\/])/i);
914
- const driveRoot = match ? match[1] : path4.win32.parse(path4.win32.resolve(filePath)).root;
915
- return fsExists(driveRoot);
916
- }
917
- const normalized = filePath.replace(/\\/g, "/");
918
- const mountMatch = normalized.match(/^(\/(?:media(?:\/[^/]+)?|mnt|run\/media(?:\/[^/]+)?)\/[^/]+)/);
919
- if (mountMatch) {
920
- return fsExists(mountMatch[1]);
921
- }
922
- let curr = path4.posix.dirname(normalized);
923
- while (curr && curr !== "/") {
924
- if (fsExists(curr)) return true;
925
- curr = path4.posix.dirname(curr);
926
- }
927
- return fsExists("/");
928
- } catch {
929
- return false;
930
- }
931
- }
932
- function resolveWindowsDownloadsDir(options = {}) {
933
- const homeDir = options.homeDir ?? os3.homedir();
934
- const env = options.env ?? process.env;
935
- const runQuery = options.regQueryFn ?? ((key, value) => {
936
- try {
937
- return execFileSync("reg", ["query", key, "/v", value], {
938
- encoding: "utf8",
939
- stdio: ["pipe", "pipe", "ignore"]
940
- });
941
- } catch {
942
- return "";
943
- }
944
- });
945
- const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs3.existsSync, "win32"));
946
- let raw = parseWindowsRegistryOutput(
947
- runQuery(USER_SHELL_FOLDERS_REG_KEY, KNOWN_FOLDER_DOWNLOADS_GUID),
948
- KNOWN_FOLDER_DOWNLOADS_GUID
949
- );
950
- if (!raw) {
951
- raw = parseWindowsRegistryOutput(runQuery(USER_SHELL_FOLDERS_REG_KEY, "Downloads"), "Downloads");
952
- }
953
- if (raw) {
954
- const expanded = expandWindowsEnv(raw, env, homeDir);
955
- if (checkAccessible(expanded)) {
956
- return path4.win32.resolve(expanded);
957
- }
958
- }
959
- return void 0;
960
- }
961
- function resolveLinuxDownloadsDir(options = {}) {
962
- const homeDir = options.homeDir ?? os3.homedir();
963
- const env = options.env ?? process.env;
964
- const readFn = options.readFileFn ?? ((p) => {
965
- try {
966
- return fs3.readFileSync(p, "utf8");
967
- } catch {
968
- return "";
969
- }
970
- });
971
- const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs3.existsSync, "linux"));
972
- const configHome = env.XDG_CONFIG_HOME || path4.join(homeDir, ".config");
973
- const userDirsFile = path4.join(configHome, "user-dirs.dirs");
974
- const content = readFn(userDirsFile);
975
- if (!content) return void 0;
976
- const raw = parseLinuxUserDirsOutput(content);
977
- if (!raw) return void 0;
978
- const expanded = raw.replace(/\$HOME/g, homeDir).replace(/\$\{HOME\}/g, homeDir);
979
- if (checkAccessible(expanded)) {
980
- return path4.posix.resolve(expanded);
981
- }
982
- return void 0;
983
- }
984
- function resolvePlatformDownloadsDir(options = {}) {
985
- const platform = options.platform ?? process.platform;
986
- const homeDir = options.homeDir ?? os3.homedir();
987
- const defaultFallback = path4.join(homeDir, "Downloads");
988
- if (platform === "win32") {
989
- const resolved = resolveWindowsDownloadsDir(options);
990
- if (resolved) return resolved;
991
- } else if (platform === "linux") {
992
- const resolved = resolveLinuxDownloadsDir(options);
993
- if (resolved) return resolved;
994
- }
995
- return defaultFallback;
996
- }
997
-
998
914
  // src/lib/engines/extractors/instagram.ts
999
915
  import crypto from "crypto";
1000
916
 
@@ -1379,8 +1295,8 @@ var InstagramExtractor = class {
1379
1295
  }
1380
1296
  const user = res.data.user;
1381
1297
  if (user.is_private) {
1382
- const fs14 = user.friendship_status;
1383
- const isFollowing = fs14 ? Boolean(fs14.following) : Boolean(user.followed_by_viewer);
1298
+ const fs18 = user.friendship_status;
1299
+ const isFollowing = fs18 ? Boolean(fs18.following) : Boolean(user.followed_by_viewer);
1384
1300
  if (!isFollowing) {
1385
1301
  throw new Error(`Instagram profile @${username} is private. You must provide a session cookie jar of an account following this profile.`);
1386
1302
  }
@@ -2947,6 +2863,30 @@ var FacebookExtractor = class {
2947
2863
  }
2948
2864
  };
2949
2865
 
2866
+ // src/lib/engines/core/plugin.ts
2867
+ var ExtractorPluginRegistry = class {
2868
+ plugins = /* @__PURE__ */ new Map();
2869
+ register(plugin) {
2870
+ this.plugins.set(plugin.platform, plugin);
2871
+ }
2872
+ get(platform) {
2873
+ return this.plugins.get(platform);
2874
+ }
2875
+ has(platform) {
2876
+ return this.plugins.has(platform);
2877
+ }
2878
+ list() {
2879
+ return Array.from(this.plugins.values());
2880
+ }
2881
+ findByUrl(url) {
2882
+ return this.list().find((plugin) => plugin.canHandle(url));
2883
+ }
2884
+ clear() {
2885
+ this.plugins.clear();
2886
+ }
2887
+ };
2888
+ var defaultPluginRegistry = new ExtractorPluginRegistry();
2889
+
2950
2890
  // src/lib/engines/extractors/index.ts
2951
2891
  function getExtractor(platform) {
2952
2892
  switch (platform) {
@@ -3750,7 +3690,7 @@ var PLATFORM_SLEEP = {
3750
3690
  facebook: "1.0-1.8"
3751
3691
  };
3752
3692
  function buildGalleryDlArgs(options) {
3753
- const { destDir, subDir, platform, cleanUrl, username, cookiePath, writeMetadata } = options;
3693
+ const { destDir, subDir, platform, cleanUrl, username, cookiePath, cookiesFromBrowser, writeMetadata } = options;
3754
3694
  const args = [
3755
3695
  "-D",
3756
3696
  destDir,
@@ -3801,6 +3741,9 @@ function buildGalleryDlArgs(options) {
3801
3741
  if (cookiePath) {
3802
3742
  args.push("--cookies", cookiePath);
3803
3743
  }
3744
+ if (cookiesFromBrowser && cookiesFromBrowser !== "none") {
3745
+ args.push("--cookies-from-browser", cookiesFromBrowser);
3746
+ }
3804
3747
  args.push("--", targetUrl);
3805
3748
  return args;
3806
3749
  }
@@ -3815,6 +3758,7 @@ async function runGalleryDl(options) {
3815
3758
  cleanUrl: profile.cleanUrl,
3816
3759
  username: profile.username,
3817
3760
  cookiePath,
3761
+ cookiesFromBrowser: options.cookiesFromBrowser,
3818
3762
  writeMetadata: options.writeMetadata
3819
3763
  });
3820
3764
  let downloadedCount = 0;
@@ -3923,7 +3867,7 @@ import fs9 from "fs/promises";
3923
3867
  var YTDLP_ARCHIVE = "archive_yt-dlp.txt";
3924
3868
  var UA2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
3925
3869
  function buildYtDlpArgs(options) {
3926
- const { destDir, platform, cleanUrl, cookiePath, writeMetadata } = options;
3870
+ const { destDir, platform, cleanUrl, cookiePath, cookiesFromBrowser, writeMetadata } = options;
3927
3871
  const args = [
3928
3872
  "-o",
3929
3873
  path9.join(destDir, "%(title).150B [%(id)s].%(ext)s"),
@@ -3957,6 +3901,9 @@ function buildYtDlpArgs(options) {
3957
3901
  if (cookiePath) {
3958
3902
  args.push("--cookies", cookiePath);
3959
3903
  }
3904
+ if (cookiesFromBrowser && cookiesFromBrowser !== "none") {
3905
+ args.push("--cookies-from-browser", cookiesFromBrowser);
3906
+ }
3960
3907
  args.push("--", cleanUrl);
3961
3908
  return args;
3962
3909
  }
@@ -3969,6 +3916,7 @@ async function runYtDlp(options) {
3969
3916
  platform: profile.platform,
3970
3917
  cleanUrl: profile.cleanUrl,
3971
3918
  cookiePath,
3919
+ cookiesFromBrowser: options.cookiesFromBrowser,
3972
3920
  writeMetadata: options.writeMetadata
3973
3921
  });
3974
3922
  let downloadedCount = 0;
@@ -4075,98 +4023,507 @@ async function runYtDlp(options) {
4075
4023
  });
4076
4024
  }
4077
4025
 
4078
- // src/lib/engines/dispatcher.ts
4079
- var REFERER_MAP = {
4080
- tiktok: "https://www.tiktok.com/",
4081
- instagram: "https://www.instagram.com/",
4082
- x: "https://x.com/",
4083
- facebook: "https://www.facebook.com/"
4084
- };
4085
- function expandPath(dir) {
4086
- if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
4087
- return path10.join(os5.homedir(), dir.slice(1));
4026
+ // src/lib/engines/core/task-manager.ts
4027
+ import path12 from "path";
4028
+ import os7 from "os";
4029
+ import fs12 from "fs/promises";
4030
+ import fsSync from "fs";
4031
+
4032
+ // src/lib/engines/core/event-bus.ts
4033
+ import { EventEmitter } from "events";
4034
+ var EngineEventBus = class {
4035
+ emitter = new EventEmitter();
4036
+ constructor() {
4037
+ this.emitter.setMaxListeners(50);
4088
4038
  }
4089
- return dir;
4090
- }
4091
- function resolveDownloadBaseDir(customBaseDir) {
4092
- if (customBaseDir && customBaseDir.trim()) {
4093
- return path10.resolve(expandPath(customBaseDir.trim()));
4039
+ on(event, listener) {
4040
+ this.emitter.on(event, listener);
4041
+ return () => this.emitter.off(event, listener);
4094
4042
  }
4095
- const envDir = process.env.OPEN_NAMI_DIR || process.env.NAMI_DIR;
4096
- if (envDir && envDir.trim()) {
4097
- return path10.resolve(expandPath(envDir.trim()));
4043
+ off(event, listener) {
4044
+ this.emitter.off(event, listener);
4098
4045
  }
4099
- const configDir = loadConfig().downloadDir;
4100
- if (configDir && configDir.trim()) {
4101
- return path10.resolve(expandPath(configDir.trim()));
4046
+ once(event, listener) {
4047
+ this.emitter.once(event, listener);
4102
4048
  }
4103
- if (process.env.NAMI_BASE_DIR && process.env.NAMI_BASE_DIR.trim()) {
4104
- const raw = process.env.NAMI_BASE_DIR.trim();
4105
- return path10.resolve(expandPath(raw.endsWith("downloads") ? raw : path10.join(raw, "downloads")));
4049
+ emit(event, payload) {
4050
+ return this.emitter.emit(event, payload);
4106
4051
  }
4107
- return path10.join(resolvePlatformDownloadsDir(), "Open Nami");
4108
- }
4109
- function resolveDefaultDownloadDir(platform, username, baseDir) {
4110
- const root = resolveDownloadBaseDir(baseDir);
4111
- return path10.join(root, platform, username);
4112
- }
4113
- async function cleanupEmptyCategoryDirs(targetDir) {
4114
- const candidateDirs = ["All", "Stories", "Highlights", "Photos", "Videos"];
4115
- for (const dirName of candidateDirs) {
4116
- const fullPath = path10.join(targetDir, dirName);
4052
+ removeAllListeners(event) {
4053
+ if (event !== void 0) {
4054
+ this.emitter.removeAllListeners(event);
4055
+ } else {
4056
+ this.emitter.removeAllListeners();
4057
+ }
4058
+ }
4059
+ };
4060
+
4061
+ // src/lib/engines/storage/ledger.ts
4062
+ import fs10 from "fs";
4063
+ import path10 from "path";
4064
+ import os5 from "os";
4065
+ import { createRequire } from "module";
4066
+ var require2 = createRequire(import.meta.url);
4067
+ var MediaLedger = class {
4068
+ db = null;
4069
+ inMemoryFallback = {
4070
+ media: /* @__PURE__ */ new Map(),
4071
+ checkpoints: /* @__PURE__ */ new Map()
4072
+ };
4073
+ isFallback = false;
4074
+ resolvedPath;
4075
+ constructor(dbPath, forceFallback = false) {
4076
+ const resolvedPath = dbPath ?? (forceFallback ? ":memory:" : path10.join(os5.homedir(), ".open-nami", "nami.db"));
4077
+ this.resolvedPath = resolvedPath;
4078
+ if (forceFallback) {
4079
+ this.isFallback = true;
4080
+ this.loadFallbackDisk();
4081
+ return;
4082
+ }
4117
4083
  try {
4118
- if (!fsSync.existsSync(fullPath)) continue;
4119
- const entries = await fs10.readdir(fullPath);
4120
- const nonArchiveEntries = entries.filter(
4121
- (e) => e !== ".open-nami-archive.json" && e !== "archive.txt" && e !== "archive_yt-dlp.txt" && e !== "archive_gallery-dl.sqlite3"
4122
- );
4123
- if (nonArchiveEntries.length === 0) {
4124
- for (const entry of entries) {
4125
- try {
4126
- await fs10.unlink(path10.join(fullPath, entry));
4127
- } catch {
4084
+ if (resolvedPath !== ":memory:") {
4085
+ fs10.mkdirSync(path10.dirname(resolvedPath), { recursive: true });
4086
+ }
4087
+ const { DatabaseSync } = require2("node:sqlite");
4088
+ this.db = new DatabaseSync(resolvedPath);
4089
+ this.initTables();
4090
+ } catch {
4091
+ this.isFallback = true;
4092
+ this.loadFallbackDisk();
4093
+ }
4094
+ }
4095
+ loadFallbackDisk() {
4096
+ if (this.resolvedPath === ":memory:") return;
4097
+ try {
4098
+ if (fs10.existsSync(this.resolvedPath)) {
4099
+ const raw = fs10.readFileSync(this.resolvedPath, "utf8");
4100
+ const data = JSON.parse(raw);
4101
+ if (data && typeof data === "object") {
4102
+ if (data.media && typeof data.media === "object") {
4103
+ for (const [k, v] of Object.entries(data.media)) {
4104
+ this.inMemoryFallback.media.set(k, v);
4105
+ }
4106
+ }
4107
+ if (data.checkpoints && typeof data.checkpoints === "object") {
4108
+ for (const [k, v] of Object.entries(data.checkpoints)) {
4109
+ this.inMemoryFallback.checkpoints.set(k, v);
4110
+ }
4128
4111
  }
4129
4112
  }
4130
- await fs10.rmdir(fullPath).catch(() => {
4131
- });
4132
4113
  }
4133
4114
  } catch {
4134
4115
  }
4135
4116
  }
4136
- }
4137
- async function dispatchDownload(options) {
4138
- const { profile, choice, onProgress } = options;
4139
- const targetDir = options.outputDir || resolveDefaultDownloadDir(profile.platform, profile.username);
4140
- await fs10.mkdir(targetDir, { recursive: true });
4141
- const masterCookie = findCookieFile(profile.platform);
4142
- const cookieCopy = masterCookie ? await createSafeCookieCopy(masterCookie) : null;
4143
- let totalDownloaded = 0;
4144
- let totalSkipped = 0;
4145
- const errors = [];
4146
- try {
4147
- const jobs = [];
4148
- if (choice === "photos") {
4149
- jobs.push({ type: "photos" });
4150
- } else if (choice === "videos") {
4151
- jobs.push({ type: "videos" });
4152
- } else if (choice === "stories") {
4153
- jobs.push({ type: "stories" });
4154
- } else if (choice === "highlights") {
4155
- jobs.push({ type: "highlights" });
4156
- } else if (choice === "all") {
4157
- jobs.push({ type: "all" });
4158
- if (profile.platform === "instagram") {
4159
- jobs.push({ type: "stories" });
4160
- jobs.push({ type: "highlights" });
4161
- }
4117
+ flushFallbackDisk() {
4118
+ if (this.resolvedPath === ":memory:") return;
4119
+ try {
4120
+ fs10.mkdirSync(path10.dirname(this.resolvedPath), { recursive: true });
4121
+ const data = {
4122
+ media: Object.fromEntries(this.inMemoryFallback.media),
4123
+ checkpoints: Object.fromEntries(this.inMemoryFallback.checkpoints)
4124
+ };
4125
+ fs10.writeFileSync(this.resolvedPath, JSON.stringify(data, null, 2), "utf8");
4126
+ } catch {
4162
4127
  }
4163
- const sinceSeconds = parseDateBoundary(options.since, "since");
4164
- const untilSeconds = parseDateBoundary(options.until, "until");
4165
- const globalLimit = parseLimit(options.limit);
4166
- const savedCheckpoint = options.resume ? await loadCheckpoint(targetDir) : null;
4167
- const currentCursors = savedCheckpoint?.cursors ? { ...savedCheckpoint.cursors } : {};
4168
- const defaultRunNative = async (subDir) => {
4169
- const dest = subDir === "All" ? targetDir : path10.join(targetDir, subDir);
4128
+ }
4129
+ initTables() {
4130
+ if (!this.db) return;
4131
+ this.db.exec(`
4132
+ CREATE TABLE IF NOT EXISTS media_ledger (
4133
+ id TEXT PRIMARY KEY,
4134
+ platform TEXT,
4135
+ author TEXT,
4136
+ url TEXT,
4137
+ filename TEXT,
4138
+ file_path TEXT,
4139
+ downloaded_at TEXT
4140
+ );
4141
+ CREATE TABLE IF NOT EXISTS checkpoints (
4142
+ key TEXT PRIMARY KEY,
4143
+ platform TEXT,
4144
+ username TEXT,
4145
+ target_dir TEXT,
4146
+ cursors_json TEXT,
4147
+ updated_at TEXT
4148
+ );
4149
+ `);
4150
+ }
4151
+ hasMedia(id) {
4152
+ if (this.isFallback || !this.db) {
4153
+ return this.inMemoryFallback.media.has(id);
4154
+ }
4155
+ const stmt = this.db.prepare("SELECT 1 FROM media_ledger WHERE id = ?");
4156
+ const row = stmt.get(id);
4157
+ return Boolean(row);
4158
+ }
4159
+ recordMedia(media) {
4160
+ if (this.isFallback || !this.db) {
4161
+ this.inMemoryFallback.media.set(media.id, media);
4162
+ this.flushFallbackDisk();
4163
+ return;
4164
+ }
4165
+ const stmt = this.db.prepare(`
4166
+ INSERT OR REPLACE INTO media_ledger (id, platform, author, url, filename, file_path, downloaded_at)
4167
+ VALUES (?, ?, ?, ?, ?, ?, ?)
4168
+ `);
4169
+ stmt.run(
4170
+ media.id,
4171
+ media.platform,
4172
+ media.author,
4173
+ media.url,
4174
+ media.filename,
4175
+ media.filePath,
4176
+ media.downloadedAt
4177
+ );
4178
+ }
4179
+ saveCheckpoint(checkpoint) {
4180
+ const key = `${checkpoint.platform}:${checkpoint.username.toLowerCase()}`;
4181
+ if (this.isFallback || !this.db) {
4182
+ this.inMemoryFallback.checkpoints.set(key, checkpoint);
4183
+ this.flushFallbackDisk();
4184
+ return;
4185
+ }
4186
+ const stmt = this.db.prepare(`
4187
+ INSERT OR REPLACE INTO checkpoints (key, platform, username, target_dir, cursors_json, updated_at)
4188
+ VALUES (?, ?, ?, ?, ?, ?)
4189
+ `);
4190
+ stmt.run(
4191
+ key,
4192
+ checkpoint.platform,
4193
+ checkpoint.username,
4194
+ checkpoint.targetDir,
4195
+ JSON.stringify(checkpoint.cursors),
4196
+ checkpoint.updatedAt
4197
+ );
4198
+ }
4199
+ getCheckpoint(platform, username) {
4200
+ const key = `${platform}:${username.toLowerCase()}`;
4201
+ if (this.isFallback || !this.db) {
4202
+ return this.inMemoryFallback.checkpoints.get(key) || null;
4203
+ }
4204
+ const stmt = this.db.prepare(
4205
+ "SELECT platform, username, target_dir, cursors_json, updated_at FROM checkpoints WHERE key = ?"
4206
+ );
4207
+ const row = stmt.get(key);
4208
+ if (!row) return null;
4209
+ return {
4210
+ platform: row.platform,
4211
+ username: row.username,
4212
+ targetDir: row.target_dir,
4213
+ cursors: JSON.parse(row.cursors_json || "{}"),
4214
+ updatedAt: row.updated_at
4215
+ };
4216
+ }
4217
+ clearCheckpoint(platform, username) {
4218
+ const key = `${platform}:${username.toLowerCase()}`;
4219
+ if (this.isFallback || !this.db) {
4220
+ this.inMemoryFallback.checkpoints.delete(key);
4221
+ this.flushFallbackDisk();
4222
+ return;
4223
+ }
4224
+ const stmt = this.db.prepare("DELETE FROM checkpoints WHERE key = ?");
4225
+ stmt.run(key);
4226
+ }
4227
+ close() {
4228
+ if (this.db) {
4229
+ try {
4230
+ this.db.close();
4231
+ } catch {
4232
+ }
4233
+ this.db = null;
4234
+ }
4235
+ }
4236
+ };
4237
+
4238
+ // src/lib/known-folders.ts
4239
+ import { execFileSync } from "child_process";
4240
+ import fs11 from "fs";
4241
+ import os6 from "os";
4242
+ import path11 from "path";
4243
+ var KNOWN_FOLDER_DOWNLOADS_GUID = "{374DE290-123F-4565-9164-39C4925E467B}";
4244
+ var USER_SHELL_FOLDERS_REG_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders";
4245
+ function expandWindowsEnv(str, env = process.env, homeDir) {
4246
+ return str.replace(/%([^%]+)%/g, (original, name) => {
4247
+ if (homeDir && name.toLowerCase() === "userprofile") {
4248
+ return homeDir;
4249
+ }
4250
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
4251
+ if (key && env[key] !== void 0) {
4252
+ return env[key];
4253
+ }
4254
+ return original;
4255
+ });
4256
+ }
4257
+ function parseWindowsRegistryOutput(output, targetKey = KNOWN_FOLDER_DOWNLOADS_GUID) {
4258
+ const lines = output.split(/\r?\n/);
4259
+ const escaped = targetKey.replace(/[{}]/g, "\\$&");
4260
+ const pattern = new RegExp(`^\\s*${escaped}\\s+REG_\\w+\\s+(.*)$`, "i");
4261
+ for (const line of lines) {
4262
+ const match = line.match(pattern);
4263
+ if (match && match[1]) {
4264
+ const rawPath = match[1].trim();
4265
+ if (rawPath) return rawPath;
4266
+ }
4267
+ }
4268
+ return void 0;
4269
+ }
4270
+ function parseLinuxUserDirsOutput(content) {
4271
+ const lines = content.split(/\r?\n/);
4272
+ for (const line of lines) {
4273
+ const trimmed = line.trim();
4274
+ if (trimmed.startsWith("#")) continue;
4275
+ const match = trimmed.match(/^XDG_DOWNLOAD_DIR=["']?([^"']+)["']?$/);
4276
+ if (match && match[1]) {
4277
+ return match[1].trim();
4278
+ }
4279
+ }
4280
+ return void 0;
4281
+ }
4282
+ function isPathDriveAccessible(filePath, fsExists = fs11.existsSync, platform = process.platform) {
4283
+ try {
4284
+ if (fsExists(filePath)) {
4285
+ return true;
4286
+ }
4287
+ if (platform === "win32") {
4288
+ const match = filePath.match(/^([a-zA-Z]:[\\/])/i);
4289
+ const driveRoot = match ? match[1] : path11.win32.parse(path11.win32.resolve(filePath)).root;
4290
+ return fsExists(driveRoot);
4291
+ }
4292
+ const normalized = filePath.replace(/\\/g, "/");
4293
+ const mountMatch = normalized.match(/^(\/(?:media(?:\/[^/]+)?|mnt|run\/media(?:\/[^/]+)?)\/[^/]+)/);
4294
+ if (mountMatch) {
4295
+ return fsExists(mountMatch[1]);
4296
+ }
4297
+ let curr = path11.posix.dirname(normalized);
4298
+ while (curr && curr !== "/") {
4299
+ if (fsExists(curr)) return true;
4300
+ curr = path11.posix.dirname(curr);
4301
+ }
4302
+ return fsExists("/");
4303
+ } catch {
4304
+ return false;
4305
+ }
4306
+ }
4307
+ function resolveWindowsDownloadsDir(options = {}) {
4308
+ const homeDir = options.homeDir ?? os6.homedir();
4309
+ const env = options.env ?? process.env;
4310
+ const runQuery = options.regQueryFn ?? ((key, value) => {
4311
+ try {
4312
+ return execFileSync("reg", ["query", key, "/v", value], {
4313
+ encoding: "utf8",
4314
+ stdio: ["pipe", "pipe", "ignore"]
4315
+ });
4316
+ } catch {
4317
+ return "";
4318
+ }
4319
+ });
4320
+ const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs11.existsSync, "win32"));
4321
+ let raw = parseWindowsRegistryOutput(
4322
+ runQuery(USER_SHELL_FOLDERS_REG_KEY, KNOWN_FOLDER_DOWNLOADS_GUID),
4323
+ KNOWN_FOLDER_DOWNLOADS_GUID
4324
+ );
4325
+ if (!raw) {
4326
+ raw = parseWindowsRegistryOutput(runQuery(USER_SHELL_FOLDERS_REG_KEY, "Downloads"), "Downloads");
4327
+ }
4328
+ if (raw) {
4329
+ const expanded = expandWindowsEnv(raw, env, homeDir);
4330
+ if (checkAccessible(expanded)) {
4331
+ return path11.win32.resolve(expanded);
4332
+ }
4333
+ }
4334
+ return void 0;
4335
+ }
4336
+ function resolveLinuxDownloadsDir(options = {}) {
4337
+ const homeDir = options.homeDir ?? os6.homedir();
4338
+ const env = options.env ?? process.env;
4339
+ const readFn = options.readFileFn ?? ((p) => {
4340
+ try {
4341
+ return fs11.readFileSync(p, "utf8");
4342
+ } catch {
4343
+ return "";
4344
+ }
4345
+ });
4346
+ const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs11.existsSync, "linux"));
4347
+ const configHome = env.XDG_CONFIG_HOME || path11.join(homeDir, ".config");
4348
+ const userDirsFile = path11.join(configHome, "user-dirs.dirs");
4349
+ const content = readFn(userDirsFile);
4350
+ if (!content) return void 0;
4351
+ const raw = parseLinuxUserDirsOutput(content);
4352
+ if (!raw) return void 0;
4353
+ const expanded = raw.replace(/\$HOME/g, homeDir).replace(/\$\{HOME\}/g, homeDir);
4354
+ if (checkAccessible(expanded)) {
4355
+ return path11.posix.resolve(expanded);
4356
+ }
4357
+ return void 0;
4358
+ }
4359
+ function resolvePlatformDownloadsDir(options = {}) {
4360
+ const platform = options.platform ?? process.platform;
4361
+ const homeDir = options.homeDir ?? os6.homedir();
4362
+ const defaultFallback = path11.join(homeDir, "Downloads");
4363
+ if (platform === "win32") {
4364
+ const resolved = resolveWindowsDownloadsDir(options);
4365
+ if (resolved) return resolved;
4366
+ } else if (platform === "linux") {
4367
+ const resolved = resolveLinuxDownloadsDir(options);
4368
+ if (resolved) return resolved;
4369
+ }
4370
+ return defaultFallback;
4371
+ }
4372
+
4373
+ // src/lib/engines/core/task-manager.ts
4374
+ function expandPath(dir) {
4375
+ if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
4376
+ return path12.join(os7.homedir(), dir.slice(1));
4377
+ }
4378
+ return dir;
4379
+ }
4380
+ function resolveDownloadBaseDir(customBaseDir) {
4381
+ if (customBaseDir && customBaseDir.trim()) {
4382
+ return path12.resolve(expandPath(customBaseDir.trim()));
4383
+ }
4384
+ const envDir = process.env.OPEN_NAMI_DIR || process.env.NAMI_DIR;
4385
+ if (envDir && envDir.trim()) {
4386
+ return path12.resolve(expandPath(envDir.trim()));
4387
+ }
4388
+ const configDir = loadConfig().downloadDir;
4389
+ if (configDir && configDir.trim()) {
4390
+ return path12.resolve(expandPath(configDir.trim()));
4391
+ }
4392
+ if (process.env.NAMI_BASE_DIR && process.env.NAMI_BASE_DIR.trim()) {
4393
+ const raw = process.env.NAMI_BASE_DIR.trim();
4394
+ return path12.resolve(expandPath(raw.endsWith("downloads") ? raw : path12.join(raw, "downloads")));
4395
+ }
4396
+ return path12.join(resolvePlatformDownloadsDir(), "Open Nami");
4397
+ }
4398
+ function resolveDefaultDownloadDir(platform, username, baseDir) {
4399
+ const root = resolveDownloadBaseDir(baseDir);
4400
+ return path12.join(root, platform, username);
4401
+ }
4402
+ async function cleanupEmptyCategoryDirs(targetDir) {
4403
+ const candidateDirs = ["All", "Stories", "Highlights", "Photos", "Videos"];
4404
+ for (const dirName of candidateDirs) {
4405
+ const fullPath = path12.join(targetDir, dirName);
4406
+ try {
4407
+ if (!fsSync.existsSync(fullPath)) continue;
4408
+ const entries = await fs12.readdir(fullPath);
4409
+ const nonArchiveEntries = entries.filter(
4410
+ (e) => e !== ".open-nami-archive.json" && e !== "archive.txt" && e !== "archive_yt-dlp.txt" && e !== "archive_gallery-dl.sqlite3"
4411
+ );
4412
+ if (nonArchiveEntries.length === 0) {
4413
+ for (const entry of entries) {
4414
+ try {
4415
+ await fs12.unlink(path12.join(fullPath, entry));
4416
+ } catch {
4417
+ }
4418
+ }
4419
+ await fs12.rmdir(fullPath).catch(() => {
4420
+ });
4421
+ }
4422
+ } catch {
4423
+ }
4424
+ }
4425
+ }
4426
+ var TaskManager = class {
4427
+ events;
4428
+ ledger;
4429
+ plugins;
4430
+ constructor(eventBus, ledger, plugins) {
4431
+ this.events = eventBus || new EngineEventBus();
4432
+ this.ledger = ledger || new MediaLedger();
4433
+ this.plugins = plugins || defaultPluginRegistry;
4434
+ }
4435
+ async execute(options) {
4436
+ const { profile, choice } = options;
4437
+ const targetDir = options.outputDir || resolveDefaultDownloadDir(profile.platform, profile.username);
4438
+ this.events.emit("job:start", { profile, choice, outputDir: targetDir });
4439
+ let downloadedCount = 0;
4440
+ let skippedCount = 0;
4441
+ const errors = [];
4442
+ if (options.runnerOverrides?.nativeRunner) {
4443
+ const subDir = choice === "all" ? "All" : choice.charAt(0).toUpperCase() + choice.slice(1);
4444
+ const runRes = await options.runnerOverrides.nativeRunner(subDir);
4445
+ downloadedCount += runRes.downloaded;
4446
+ skippedCount += runRes.skipped;
4447
+ if (!runRes.success) errors.push(runRes.error || "Execution failed");
4448
+ }
4449
+ await cleanupEmptyCategoryDirs(targetDir);
4450
+ const result = {
4451
+ success: errors.length === 0,
4452
+ downloadedCount,
4453
+ skippedCount,
4454
+ errors,
4455
+ outputDir: targetDir
4456
+ };
4457
+ this.events.emit("job:complete", { result });
4458
+ return result;
4459
+ }
4460
+ };
4461
+ var defaultTaskManager = new TaskManager();
4462
+
4463
+ // src/lib/engines/dispatcher.ts
4464
+ var REFERER_MAP = {
4465
+ tiktok: "https://www.tiktok.com/",
4466
+ instagram: "https://www.instagram.com/",
4467
+ x: "https://x.com/",
4468
+ facebook: "https://www.facebook.com/"
4469
+ };
4470
+ async function dispatchDownload(options) {
4471
+ const { profile, choice, onProgress } = options;
4472
+ const targetDir = options.outputDir || resolveDefaultDownloadDir(profile.platform, profile.username);
4473
+ await fs13.mkdir(targetDir, { recursive: true });
4474
+ const cookiesFromBrowser = options.cookiesFromBrowser ?? loadConfig().cookiesFromBrowser;
4475
+ defaultTaskManager.events.emit("job:start", { profile, choice, outputDir: targetDir });
4476
+ const masterCookie = findCookieFile(profile.platform);
4477
+ const cookieCopy = masterCookie ? await createSafeCookieCopy(masterCookie) : null;
4478
+ let totalDownloaded = 0;
4479
+ let totalSkipped = 0;
4480
+ const errors = [];
4481
+ const recordError = (err) => {
4482
+ errors.push(err);
4483
+ defaultTaskManager.events.emit("job:error", { error: err });
4484
+ };
4485
+ const handleProgress = (progress) => {
4486
+ defaultTaskManager.events.emit("job:progress", progress);
4487
+ if (progress.currentFile && progress.statusText?.startsWith("Saved:")) {
4488
+ try {
4489
+ defaultTaskManager.ledger.recordMedia({
4490
+ id: `${profile.platform}:${profile.username}:${progress.currentFile}`,
4491
+ platform: profile.platform,
4492
+ author: profile.username,
4493
+ url: profile.cleanUrl || "",
4494
+ filename: progress.currentFile,
4495
+ filePath: path13.join(targetDir, progress.currentFile),
4496
+ downloadedAt: (/* @__PURE__ */ new Date()).toISOString()
4497
+ });
4498
+ } catch {
4499
+ }
4500
+ }
4501
+ onProgress?.(progress);
4502
+ };
4503
+ try {
4504
+ const jobs = [];
4505
+ if (choice === "photos") {
4506
+ jobs.push({ type: "photos" });
4507
+ } else if (choice === "videos") {
4508
+ jobs.push({ type: "videos" });
4509
+ } else if (choice === "stories") {
4510
+ jobs.push({ type: "stories" });
4511
+ } else if (choice === "highlights") {
4512
+ jobs.push({ type: "highlights" });
4513
+ } else if (choice === "all") {
4514
+ jobs.push({ type: "all" });
4515
+ if (profile.platform === "instagram") {
4516
+ jobs.push({ type: "stories" });
4517
+ jobs.push({ type: "highlights" });
4518
+ }
4519
+ }
4520
+ const sinceSeconds = parseDateBoundary(options.since, "since");
4521
+ const untilSeconds = parseDateBoundary(options.until, "until");
4522
+ const globalLimit = parseLimit(options.limit);
4523
+ const savedCheckpoint = options.resume ? await loadCheckpoint(targetDir) : null;
4524
+ const currentCursors = savedCheckpoint?.cursors ? { ...savedCheckpoint.cursors } : {};
4525
+ const defaultRunNative = async (subDir) => {
4526
+ const dest = subDir === "All" ? targetDir : path13.join(targetDir, subDir);
4170
4527
  const remainingLimit = globalLimit !== void 0 ? Math.max(0, globalLimit - totalDownloaded) : void 0;
4171
4528
  if (remainingLimit === 0) {
4172
4529
  return { success: true, downloaded: 0, skipped: 0 };
@@ -4208,8 +4565,8 @@ async function dispatchDownload(options) {
4208
4565
  jobName: `${profile.username} ${subDir}`,
4209
4566
  destDir: dest,
4210
4567
  destDirMap: subDir === "All" ? {
4211
- photo: path10.join(targetDir, "Photos"),
4212
- video: path10.join(targetDir, "Videos")
4568
+ photo: path13.join(targetDir, "Photos"),
4569
+ video: path13.join(targetDir, "Videos")
4213
4570
  } : void 0,
4214
4571
  cookiePath: cookieCopy?.filePath,
4215
4572
  referer: REFERER_MAP[profile.platform],
@@ -4218,7 +4575,7 @@ async function dispatchDownload(options) {
4218
4575
  writeMetadata: options.writeMetadata,
4219
4576
  defaultPlatform: profile.platform,
4220
4577
  defaultAuthor: profile.username,
4221
- onProgress
4578
+ onProgress: handleProgress
4222
4579
  });
4223
4580
  return {
4224
4581
  success: res.errors.length === 0,
@@ -4232,7 +4589,7 @@ async function dispatchDownload(options) {
4232
4589
  success: false,
4233
4590
  downloaded: 0,
4234
4591
  skipped: 0,
4235
- error: errMsg
4592
+ error: errMsg || "Execution failed"
4236
4593
  };
4237
4594
  }
4238
4595
  };
@@ -4242,17 +4599,19 @@ async function dispatchDownload(options) {
4242
4599
  targetDir,
4243
4600
  subDir,
4244
4601
  cookiePath: cookieCopy?.filePath,
4602
+ cookiesFromBrowser,
4245
4603
  signal: options.signal,
4246
4604
  writeMetadata: options.writeMetadata,
4247
- onProgress
4605
+ onProgress: handleProgress
4248
4606
  }));
4249
4607
  const runYt = options.runnerOverrides?.ytRunner ?? (() => runYtDlp({
4250
4608
  profile,
4251
4609
  targetDir,
4252
4610
  cookiePath: cookieCopy?.filePath,
4611
+ cookiesFromBrowser,
4253
4612
  signal: options.signal,
4254
4613
  writeMetadata: options.writeMetadata,
4255
- onProgress
4614
+ onProgress: handleProgress
4256
4615
  }));
4257
4616
  for (const job of jobs) {
4258
4617
  if (options.signal?.aborted) break;
@@ -4260,7 +4619,7 @@ async function dispatchDownload(options) {
4260
4619
  if (job.type === "all") {
4261
4620
  let res = await runNative("All");
4262
4621
  if ((!res.success || res.downloaded === 0 && res.skipped === 0) && !options.signal?.aborted) {
4263
- onProgress?.({
4622
+ handleProgress({
4264
4623
  job: "Photos",
4265
4624
  downloadedCount: totalDownloaded,
4266
4625
  skippedCount: totalSkipped,
@@ -4269,8 +4628,8 @@ async function dispatchDownload(options) {
4269
4628
  const gdlPhotos = await runGdl("Photos");
4270
4629
  totalDownloaded += gdlPhotos.downloaded;
4271
4630
  totalSkipped += gdlPhotos.skipped;
4272
- if (!gdlPhotos.success && gdlPhotos.error) errors.push(`[Photos] ${gdlPhotos.error}`);
4273
- onProgress?.({
4631
+ if (!gdlPhotos.success) recordError(gdlPhotos.error ? `[Photos] ${gdlPhotos.error}` : "[Photos] Execution failed");
4632
+ handleProgress({
4274
4633
  job: "Videos",
4275
4634
  downloadedCount: totalDownloaded,
4276
4635
  skippedCount: totalSkipped,
@@ -4280,9 +4639,9 @@ async function dispatchDownload(options) {
4280
4639
  if (ytVideos.downloaded > 0 || ytVideos.skipped > 0 || ytVideos.success) {
4281
4640
  totalDownloaded += ytVideos.downloaded;
4282
4641
  totalSkipped += ytVideos.skipped;
4283
- if (!ytVideos.success && ytVideos.error) errors.push(`[Videos] ${ytVideos.error}`);
4642
+ if (!ytVideos.success) recordError(ytVideos.error ? `[Videos] ${ytVideos.error}` : "[Videos] Execution failed");
4284
4643
  } else if (!options.signal?.aborted) {
4285
- onProgress?.({
4644
+ handleProgress({
4286
4645
  job: "Videos",
4287
4646
  downloadedCount: totalDownloaded,
4288
4647
  skippedCount: totalSkipped,
@@ -4291,19 +4650,19 @@ async function dispatchDownload(options) {
4291
4650
  const gdlVideos = await runGdl("Videos");
4292
4651
  totalDownloaded += gdlVideos.downloaded;
4293
4652
  totalSkipped += gdlVideos.skipped;
4294
- if (!gdlVideos.success && gdlVideos.error) errors.push(`[Videos] ${gdlVideos.error}`);
4653
+ if (!gdlVideos.success) recordError(gdlVideos.error ? `[Videos] ${gdlVideos.error}` : "[Videos] Execution failed");
4295
4654
  }
4296
4655
  } else {
4297
4656
  totalDownloaded += res.downloaded;
4298
4657
  totalSkipped += res.skipped;
4299
- if (!res.success && res.error) errors.push(`[All] ${res.error}`);
4658
+ if (!res.success) recordError(res.error ? `[All] ${res.error}` : "[All] Execution failed");
4300
4659
  }
4301
4660
  } else if (job.type === "photos" || job.type === "stories" || job.type === "highlights") {
4302
4661
  const subDir = job.type === "photos" ? "Photos" : job.type === "stories" ? "Stories" : "Highlights";
4303
4662
  let res = await runNative(subDir);
4304
4663
  const isAuthError = Boolean(res.error && (res.error.includes("cookie") || res.error.includes("authenticated session") || res.error.includes("Settings (^s)")));
4305
4664
  if (!isAuthError && (!res.success || res.downloaded === 0 && res.skipped === 0) && !options.signal?.aborted) {
4306
- onProgress?.({
4665
+ handleProgress({
4307
4666
  job: subDir,
4308
4667
  downloadedCount: totalDownloaded,
4309
4668
  skippedCount: totalSkipped,
@@ -4316,14 +4675,14 @@ async function dispatchDownload(options) {
4316
4675
  }
4317
4676
  totalDownloaded += res.downloaded;
4318
4677
  totalSkipped += res.skipped;
4319
- if (!res.success && res.error) errors.push(`[${subDir}] ${res.error}`);
4678
+ if (!res.success) recordError(res.error ? `[${subDir}] ${res.error}` : `[${subDir}] Execution failed`);
4320
4679
  } else if (job.type === "videos") {
4321
4680
  let res = await runNative("Videos");
4322
4681
  if (res.downloaded > 0 || res.skipped > 0) {
4323
4682
  totalDownloaded += res.downloaded;
4324
4683
  totalSkipped += res.skipped;
4325
4684
  } else if (!options.signal?.aborted) {
4326
- onProgress?.({
4685
+ handleProgress({
4327
4686
  job: "Videos",
4328
4687
  downloadedCount: totalDownloaded,
4329
4688
  skippedCount: totalSkipped,
@@ -4333,9 +4692,9 @@ async function dispatchDownload(options) {
4333
4692
  if (fbRes.downloaded > 0 || fbRes.skipped > 0 || fbRes.success) {
4334
4693
  totalDownloaded += fbRes.downloaded;
4335
4694
  totalSkipped += fbRes.skipped;
4336
- if (!fbRes.success && fbRes.error) errors.push(`[Videos] ${fbRes.error}`);
4695
+ if (!fbRes.success) recordError(fbRes.error ? `[Videos] ${fbRes.error}` : "[Videos] Execution failed");
4337
4696
  } else if (!options.signal?.aborted) {
4338
- onProgress?.({
4697
+ handleProgress({
4339
4698
  job: "Videos",
4340
4699
  downloadedCount: totalDownloaded,
4341
4700
  skippedCount: totalSkipped,
@@ -4344,7 +4703,7 @@ async function dispatchDownload(options) {
4344
4703
  const gdlRes = await runGdl("Videos");
4345
4704
  totalDownloaded += gdlRes.downloaded;
4346
4705
  totalSkipped += gdlRes.skipped;
4347
- if (!gdlRes.success && gdlRes.error) errors.push(`[Videos] ${gdlRes.error}`);
4706
+ if (!gdlRes.success) recordError(gdlRes.error ? `[Videos] ${gdlRes.error}` : "[Videos] Execution failed");
4348
4707
  }
4349
4708
  }
4350
4709
  }
@@ -4353,13 +4712,19 @@ async function dispatchDownload(options) {
4353
4712
  if (errors.length === 0 && !options.signal?.aborted) {
4354
4713
  await clearCheckpoint(targetDir);
4355
4714
  }
4356
- return {
4715
+ const engineResult = {
4357
4716
  success: errors.length === 0 || totalDownloaded > 0,
4358
4717
  downloadedCount: totalDownloaded,
4359
4718
  skippedCount: totalSkipped,
4360
4719
  errors,
4361
4720
  outputDir: targetDir
4362
4721
  };
4722
+ defaultTaskManager.events.emit("job:complete", { result: engineResult });
4723
+ return engineResult;
4724
+ } catch (err) {
4725
+ const errMsg = err instanceof Error ? err.message : String(err);
4726
+ defaultTaskManager.events.emit("job:error", { error: errMsg || "Execution failed" });
4727
+ throw err;
4363
4728
  } finally {
4364
4729
  if (cookieCopy) {
4365
4730
  await cookieCopy.cleanup();
@@ -4368,7 +4733,7 @@ async function dispatchDownload(options) {
4368
4733
  }
4369
4734
 
4370
4735
  // src/components/views/picking-view.tsx
4371
- import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
4736
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
4372
4737
  function ChoiceIndicator({ isSelected }) {
4373
4738
  const theme = useTheme();
4374
4739
  return /* @__PURE__ */ jsxs6(Box6, { children: [
@@ -4416,7 +4781,7 @@ function getPickingOptions(platform) {
4416
4781
  return items;
4417
4782
  }
4418
4783
  function PickingView({
4419
- contentWidth: _contentWidth,
4784
+ contentWidth,
4420
4785
  profile,
4421
4786
  outputDir,
4422
4787
  initialLimit,
@@ -4427,7 +4792,7 @@ function PickingView({
4427
4792
  onSelect,
4428
4793
  onHighlight,
4429
4794
  theme,
4430
- gapComponent: _Gap
4795
+ gapComponent: Gap2
4431
4796
  }) {
4432
4797
  const platformName = profile.platform.charAt(0).toUpperCase() + profile.platform.slice(1);
4433
4798
  const items = getPickingOptions(profile.platform);
@@ -4455,48 +4820,44 @@ function PickingView({
4455
4820
  resume: initialResume
4456
4821
  });
4457
4822
  };
4458
- return /* @__PURE__ */ jsx8(Box6, { flexDirection: "column", alignItems: "center", width: "100%", children: /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", width: 56, children: [
4459
- /* @__PURE__ */ jsxs6(Box6, { children: [
4460
- /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: "\u258E " }),
4823
+ const boxWidth = Math.min(48, Math.max(44, (contentWidth || 80) - 8));
4824
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", alignItems: "center", width: "100%", children: [
4825
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", alignItems: "center", marginBottom: 1, children: [
4461
4826
  /* @__PURE__ */ jsxs6(Text7, { bold: true, color: theme.primary, children: [
4462
4827
  "@",
4463
4828
  profile.username
4464
4829
  ] }),
4465
- /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: ` \xB7 ${platformName}` })
4466
- ] }),
4467
- profile.cleanUrl ? /* @__PURE__ */ jsxs6(Box6, { children: [
4468
- /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: "\u258E " }),
4469
- /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: profile.cleanUrl })
4470
- ] }) : null,
4471
- /* @__PURE__ */ jsx8(Box6, { children: /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: "\u258E" }) }),
4472
- /* @__PURE__ */ jsxs6(Box6, { children: [
4473
- /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: "\u258E " }),
4830
+ /* @__PURE__ */ jsx8(Gap2, {}),
4831
+ /* @__PURE__ */ jsxs6(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: [
4832
+ "\u25B8 ",
4833
+ platformName,
4834
+ profile.cleanUrl ? ` \xB7 ${profile.cleanUrl}` : ""
4835
+ ] }),
4836
+ /* @__PURE__ */ jsx8(Gap2, {}),
4474
4837
  /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: "Select target media:" })
4475
4838
  ] }),
4476
- /* @__PURE__ */ jsx8(Box6, { children: /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: "\u258E" }) }),
4477
- /* @__PURE__ */ jsx8(
4478
- SelectInput,
4479
- {
4480
- indicatorComponent: ChoiceIndicator,
4481
- itemComponent: ChoiceItem,
4482
- items: items.map((item, index) => ({
4483
- key: String(index),
4484
- label: item.label,
4485
- value: item.value,
4486
- description: item.description
4487
- })),
4488
- onSelect: (item) => handleItemSelect(item.value),
4489
- onHighlight: (item) => onHighlight?.(item.value)
4490
- }
4491
- ),
4492
- hasCheckpoint ? /* @__PURE__ */ jsxs6(Fragment2, { children: [
4493
- /* @__PURE__ */ jsx8(Box6, { children: /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: "\u258E" }) }),
4494
- /* @__PURE__ */ jsxs6(Box6, { children: [
4839
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", width: boxWidth, children: [
4840
+ /* @__PURE__ */ jsx8(
4841
+ SelectInput,
4842
+ {
4843
+ indicatorComponent: ChoiceIndicator,
4844
+ itemComponent: ChoiceItem,
4845
+ items: items.map((item, index) => ({
4846
+ key: String(index),
4847
+ label: item.label,
4848
+ value: item.value,
4849
+ description: item.description
4850
+ })),
4851
+ onSelect: (item) => handleItemSelect(item.value),
4852
+ onHighlight: (item) => onHighlight?.(item.value)
4853
+ }
4854
+ ),
4855
+ hasCheckpoint ? /* @__PURE__ */ jsxs6(Box6, { marginTop: 1, children: [
4495
4856
  /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: "\u258E " }),
4496
4857
  /* @__PURE__ */ jsx8(Text7, { color: theme.primary, bold: true, children: "\u26A1 Checkpoint found (cursor saved)" })
4497
- ] })
4498
- ] }) : null
4499
- ] }) });
4858
+ ] }) : null
4859
+ ] })
4860
+ ] });
4500
4861
  }
4501
4862
 
4502
4863
  // src/components/views/platform-view.tsx
@@ -4563,7 +4924,7 @@ function ProbingView({
4563
4924
 
4564
4925
  // src/components/views/downloading-view.tsx
4565
4926
  import { useState as useState5, useEffect as useEffect5 } from "react";
4566
- import os6 from "os";
4927
+ import os8 from "os";
4567
4928
  import { Box as Box9, Text as Text11 } from "ink";
4568
4929
  import Spinner from "ink-spinner";
4569
4930
 
@@ -4699,35 +5060,51 @@ function DownloadingView({
4699
5060
  activeBadges.length > 0 ? /* @__PURE__ */ jsx12(Text11, { color: theme.gray, dimColor: theme.dimSecondary, children: activeBadges.join(" \xB7 ") }) : null,
4700
5061
  outputDir ? /* @__PURE__ */ jsxs9(Text11, { color: theme.gray, dimColor: theme.dimSecondary, children: [
4701
5062
  "saving to ",
4702
- shortenPath(outputDir, os6.homedir(), 54)
5063
+ shortenPath(outputDir, os8.homedir(), 54)
4703
5064
  ] }) : null
4704
5065
  ] });
4705
5066
  }
4706
5067
 
4707
5068
  // src/components/views/completion-view.tsx
4708
- import os7 from "os";
5069
+ import os9 from "os";
4709
5070
  import { Box as Box10, Text as Text12 } from "ink";
4710
- import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
5071
+ import { jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
4711
5072
  var DONE_LABEL = "\u21B5 download another";
5073
+ var LEDGER_INDEXED_LABEL = "\u2713 indexed in media ledger";
5074
+ function shouldShowLedgerBadge(result, showLedgerBadge) {
5075
+ return showLedgerBadge ?? result.downloadedCount > 0;
5076
+ }
5077
+ function formatCompletionSummary(result) {
5078
+ const isZero = result.downloadedCount === 0 && result.skippedCount === 0;
5079
+ const plural = result.downloadedCount === 1 ? "" : "s";
5080
+ const skipText = result.skippedCount > 0 ? ` (${result.skippedCount} archived)` : "";
5081
+ return {
5082
+ isZero,
5083
+ headerPrefix: isZero ? "\u2713 profile scanned " : "\u2713 profile downloaded! ",
5084
+ headerSuffix: isZero ? ": 0 media items found" : "saved to:",
5085
+ itemsText: isZero ? "no photos or videos available" : `${result.downloadedCount} media item${plural} downloaded${skipText}`
5086
+ };
5087
+ }
5088
+ function LedgerBadge({ theme }) {
5089
+ return /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: LEDGER_INDEXED_LABEL });
5090
+ }
4712
5091
  function CompletionView({
4713
5092
  profile,
4714
5093
  result,
4715
5094
  theme,
4716
- gapComponent: Gap2
5095
+ gapComponent: Gap2,
5096
+ showLedgerBadge
4717
5097
  }) {
4718
- const isZero = result.downloadedCount === 0 && result.skippedCount === 0;
4719
- const plural = result.downloadedCount === 1 ? "" : "s";
4720
- const skipText = result.skippedCount > 0 ? ` (${result.skippedCount} archived)` : "";
5098
+ const { headerPrefix, headerSuffix, itemsText } = formatCompletionSummary(result);
5099
+ const displayLedger = shouldShowLedgerBadge(result, showLedgerBadge);
4721
5100
  return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", alignItems: "center", children: [
4722
- /* @__PURE__ */ jsx13(Text12, { children: isZero ? /* @__PURE__ */ jsxs10(Fragment3, { children: [
4723
- /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: "\u2713 profile scanned " }),
4724
- /* @__PURE__ */ jsx13(Text12, { color: theme.primary, children: "\u2014 0 media items found" })
4725
- ] }) : /* @__PURE__ */ jsxs10(Fragment3, { children: [
4726
- /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: "\u2713 profile downloaded! " }),
4727
- /* @__PURE__ */ jsx13(Text12, { color: theme.primary, children: "saved to:" })
4728
- ] }) }),
4729
- /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: shortenPath(result.outputDir, os7.homedir(), 60) }),
4730
- /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: isZero ? "no photos or videos available" : `${result.downloadedCount} media item${plural} downloaded${skipText}` }),
5101
+ /* @__PURE__ */ jsxs10(Text12, { children: [
5102
+ /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: headerPrefix }),
5103
+ /* @__PURE__ */ jsx13(Text12, { color: theme.primary, children: headerSuffix })
5104
+ ] }),
5105
+ /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: shortenPath(result.outputDir, os9.homedir(), 60) }),
5106
+ /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: itemsText }),
5107
+ displayLedger && /* @__PURE__ */ jsx13(LedgerBadge, { theme }),
4731
5108
  /* @__PURE__ */ jsx13(Box10, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: [
4732
5109
  /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: "[o]" }),
4733
5110
  " reveal in folder"
@@ -4769,18 +5146,206 @@ function ErrorView({ message, hint, columns, theme }) {
4769
5146
  }
4770
5147
 
4771
5148
  // src/components/views/settings-view.tsx
4772
- import { useState as useState6, useMemo as useMemo2, useCallback } from "react";
5149
+ import { useState as useState6, useMemo as useMemo2, useCallback, useEffect as useEffect6 } from "react";
4773
5150
  import { Box as Box12, Text as Text14, useInput as useInput2 } from "ink";
4774
- import fs11 from "fs";
4775
- import path11 from "path";
4776
- import os8 from "os";
4777
- import { jsx as jsx15, jsxs as jsxs12 } from "react/jsx-runtime";
5151
+ import fs15 from "fs";
5152
+ import path15 from "path";
5153
+ import os11 from "os";
5154
+
5155
+ // src/lib/diagnostics/doctor.ts
5156
+ import { execFile } from "child_process";
5157
+ import { promisify } from "util";
5158
+ import os10 from "os";
5159
+ import fs14 from "fs";
5160
+ import path14 from "path";
5161
+ var execFileAsync = promisify(execFile);
5162
+ async function defaultCheckBinary(bin) {
5163
+ const versionFlag = bin === "ffmpeg" ? "-version" : "--version";
5164
+ try {
5165
+ await execFileAsync(bin, [versionFlag], { timeout: 3e3 });
5166
+ return true;
5167
+ } catch {
5168
+ if (bin === "gallery-dl" || bin === "yt-dlp") {
5169
+ try {
5170
+ const resolved = resolveEngineCommand(bin);
5171
+ await execFileAsync(resolved.command, [...resolved.baseArgs, versionFlag], { timeout: 3e3 });
5172
+ return true;
5173
+ } catch {
5174
+ return false;
5175
+ }
5176
+ }
5177
+ return false;
5178
+ }
5179
+ }
5180
+ async function runDoctor(options = {}) {
5181
+ const check = options.binaryChecker ?? defaultCheckBinary;
5182
+ const [gdl, ytdlp, ffmpeg] = await Promise.all([
5183
+ check("gallery-dl"),
5184
+ check("yt-dlp"),
5185
+ check("ffmpeg")
5186
+ ]);
5187
+ const availableCookies = detectAvailableCookies(options.cookiesDir);
5188
+ const cookieStatus = {
5189
+ instagram: availableCookies.includes("instagram"),
5190
+ tiktok: availableCookies.includes("tiktok"),
5191
+ facebook: availableCookies.includes("facebook"),
5192
+ x: availableCookies.includes("x")
5193
+ };
5194
+ let storageOk = false;
5195
+ try {
5196
+ const testDir = options.storageDir || path14.join(os10.homedir(), ".open-nami");
5197
+ fs14.mkdirSync(testDir, { recursive: true });
5198
+ const testFile = path14.join(testDir, `.doctor_test_${Date.now()}_${Math.random().toString(36).slice(2)}`);
5199
+ fs14.writeFileSync(testFile, "ok");
5200
+ fs14.unlinkSync(testFile);
5201
+ storageOk = true;
5202
+ } catch {
5203
+ }
5204
+ return {
5205
+ nodeVersion: process.version,
5206
+ binaries: {
5207
+ "gallery-dl": gdl,
5208
+ "yt-dlp": ytdlp,
5209
+ ffmpeg
5210
+ },
5211
+ cookies: cookieStatus,
5212
+ storageOk
5213
+ };
5214
+ }
5215
+
5216
+ // src/components/views/settings-view.tsx
5217
+ import { Fragment as Fragment2, jsx as jsx15, jsxs as jsxs12 } from "react/jsx-runtime";
4778
5218
  function expandDir(dir) {
4779
5219
  if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
4780
- return path11.join(os8.homedir(), dir.slice(1));
5220
+ return path15.join(os11.homedir(), dir.slice(1));
4781
5221
  }
4782
5222
  return dir;
4783
5223
  }
5224
+ function resolveBrowserOptions(detected) {
5225
+ const available = detected.filter((b) => b.available !== false);
5226
+ return ["none", ...available.map((b) => b.id)];
5227
+ }
5228
+ function cycleBrowserOption(current, options, forward = true) {
5229
+ if (options.length === 0) return "none";
5230
+ const idx = options.indexOf(current);
5231
+ const currentIndex = idx === -1 ? 0 : idx;
5232
+ const nextIdx = forward ? (currentIndex + 1) % options.length : (currentIndex - 1 + options.length) % options.length;
5233
+ return options[nextIdx];
5234
+ }
5235
+ function formatDoctorStorage(storageOk) {
5236
+ return {
5237
+ ok: storageOk,
5238
+ text: storageOk ? "\u2713 SQLite ready" : "\u2717 SQLite unavailable"
5239
+ };
5240
+ }
5241
+ function formatDoctorCookies(cookies) {
5242
+ const platforms = Object.entries(cookies).filter(([_, ok]) => ok).map(([name]) => name);
5243
+ const count = platforms.length;
5244
+ const text = count > 0 ? `\u2713 ${count} (${platforms.join(", ")})` : "none detected";
5245
+ return { count, platforms, text };
5246
+ }
5247
+ function DoctorCard({ boxWidth, theme, report }) {
5248
+ const cookieSummary = report ? formatDoctorCookies(report.cookies) : { count: 0, platforms: [], text: "" };
5249
+ const storageSummary = report ? formatDoctorStorage(report.storageOk) : { ok: false, text: "" };
5250
+ return /* @__PURE__ */ jsxs12(
5251
+ Box12,
5252
+ {
5253
+ width: boxWidth,
5254
+ borderStyle: "round",
5255
+ borderColor: theme.gray,
5256
+ borderDimColor: theme.dimSecondary,
5257
+ paddingX: 2,
5258
+ flexDirection: "column",
5259
+ children: [
5260
+ /* @__PURE__ */ jsxs12(Box12, { justifyContent: "space-between", children: [
5261
+ /* @__PURE__ */ jsx15(Text14, { color: theme.primary, bold: true, children: "System Diagnostics" }),
5262
+ /* @__PURE__ */ jsx15(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: report ? report.nodeVersion : "checking\u2026" })
5263
+ ] }),
5264
+ report ? /* @__PURE__ */ jsxs12(Fragment2, { children: [
5265
+ /* @__PURE__ */ jsxs12(Box12, { marginTop: 0, children: [
5266
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5267
+ "Engines:",
5268
+ " "
5269
+ ] }),
5270
+ /* @__PURE__ */ jsxs12(
5271
+ Text14,
5272
+ {
5273
+ color: report.binaries["gallery-dl"] ? "green" : "red",
5274
+ dimColor: !report.binaries["gallery-dl"] && theme.dimSecondary,
5275
+ children: [
5276
+ report.binaries["gallery-dl"] ? "\u2713" : "\u2717",
5277
+ " gallery-dl"
5278
+ ]
5279
+ }
5280
+ ),
5281
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5282
+ " ",
5283
+ "\xB7",
5284
+ " "
5285
+ ] }),
5286
+ /* @__PURE__ */ jsxs12(
5287
+ Text14,
5288
+ {
5289
+ color: report.binaries["yt-dlp"] ? "green" : "red",
5290
+ dimColor: !report.binaries["yt-dlp"] && theme.dimSecondary,
5291
+ children: [
5292
+ report.binaries["yt-dlp"] ? "\u2713" : "\u2717",
5293
+ " yt-dlp"
5294
+ ]
5295
+ }
5296
+ ),
5297
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5298
+ " ",
5299
+ "\xB7",
5300
+ " "
5301
+ ] }),
5302
+ /* @__PURE__ */ jsxs12(
5303
+ Text14,
5304
+ {
5305
+ color: report.binaries.ffmpeg ? "green" : "red",
5306
+ dimColor: !report.binaries.ffmpeg && theme.dimSecondary,
5307
+ children: [
5308
+ report.binaries.ffmpeg ? "\u2713" : "\u2717",
5309
+ " ffmpeg"
5310
+ ]
5311
+ }
5312
+ )
5313
+ ] }),
5314
+ /* @__PURE__ */ jsxs12(Box12, { justifyContent: "space-between", marginTop: 0, children: [
5315
+ /* @__PURE__ */ jsxs12(Box12, { children: [
5316
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5317
+ "Storage:",
5318
+ " "
5319
+ ] }),
5320
+ /* @__PURE__ */ jsx15(
5321
+ Text14,
5322
+ {
5323
+ color: storageSummary.ok ? "green" : "red",
5324
+ dimColor: !storageSummary.ok && theme.dimSecondary,
5325
+ children: storageSummary.text
5326
+ }
5327
+ )
5328
+ ] }),
5329
+ /* @__PURE__ */ jsxs12(Box12, { children: [
5330
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5331
+ "Cookies:",
5332
+ " "
5333
+ ] }),
5334
+ /* @__PURE__ */ jsx15(
5335
+ Text14,
5336
+ {
5337
+ color: cookieSummary.count > 0 ? theme.primary : theme.gray,
5338
+ dimColor: cookieSummary.count === 0 && theme.dimSecondary,
5339
+ children: cookieSummary.text
5340
+ }
5341
+ )
5342
+ ] })
5343
+ ] })
5344
+ ] }) : /* @__PURE__ */ jsx15(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: "checking system engines and storage\u2026" })
5345
+ ]
5346
+ }
5347
+ );
5348
+ }
4784
5349
  var LIMIT_OPTIONS = [void 0, 10, 25, 50, 100];
4785
5350
  function SettingsView({
4786
5351
  boxWidth,
@@ -4789,7 +5354,10 @@ function SettingsView({
4789
5354
  focusedField: controlledFocus,
4790
5355
  onFocusChange,
4791
5356
  onSave,
4792
- onCancel
5357
+ onCancel,
5358
+ doctorChecker,
5359
+ initialReport,
5360
+ browsers: browsersProp
4793
5361
  }) {
4794
5362
  const initialConfig = useMemo2(() => loadConfig(), []);
4795
5363
  const [internalFocus, setInternalFocus] = useState6("cookies");
@@ -4801,16 +5369,61 @@ function SettingsView({
4801
5369
  },
4802
5370
  [onFocusChange]
4803
5371
  );
5372
+ const detectedBrowsers = useMemo2(() => {
5373
+ return browsersProp ?? listDetectedBrowsers();
5374
+ }, [browsersProp]);
5375
+ const browserOptions = useMemo2(() => {
5376
+ return resolveBrowserOptions(detectedBrowsers);
5377
+ }, [detectedBrowsers]);
4804
5378
  const [cookiesDir, setCookiesDir] = useState6(initialConfig.cookiesDir ?? "");
5379
+ const [cookiesFromBrowser, setCookiesFromBrowser] = useState6(
5380
+ initialConfig.cookiesFromBrowser ?? "none"
5381
+ );
4805
5382
  const [downloadDir, setDownloadDir] = useState6(initialConfig.downloadDir ?? "");
4806
5383
  const [writeMetadata, setWriteMetadata] = useState6(Boolean(initialConfig.writeMetadata));
4807
5384
  const [defaultLimit, setDefaultLimit] = useState6(initialConfig.defaultLimit);
4808
5385
  const [saved, setSaved] = useState6(false);
5386
+ const [doctorReport, setDoctorReport] = useState6(initialReport ?? null);
5387
+ useEffect6(() => {
5388
+ let cancelled = false;
5389
+ const fetchReport = async () => {
5390
+ try {
5391
+ const options = {
5392
+ cookiesDir: cookiesDir.trim() ? expandDir(cookiesDir.trim()) : void 0
5393
+ };
5394
+ let report;
5395
+ if (doctorChecker) {
5396
+ const res = await doctorChecker(options);
5397
+ if (res && typeof res === "object" && "binaries" in res) {
5398
+ report = res;
5399
+ } else {
5400
+ report = await runDoctor({
5401
+ ...options,
5402
+ binaryChecker: doctorChecker
5403
+ });
5404
+ }
5405
+ } else {
5406
+ report = await runDoctor(options);
5407
+ }
5408
+ if (!cancelled) {
5409
+ setDoctorReport(report);
5410
+ }
5411
+ } catch {
5412
+ }
5413
+ };
5414
+ if (initialReport === void 0 || doctorChecker !== void 0) {
5415
+ fetchReport();
5416
+ }
5417
+ return () => {
5418
+ cancelled = true;
5419
+ };
5420
+ }, [cookiesDir, doctorChecker, initialReport]);
4809
5421
  const handleSave = useCallback(() => {
4810
5422
  const existing = loadConfig();
4811
5423
  const nextConfig = {
4812
5424
  ...existing,
4813
5425
  cookiesDir: cookiesDir.trim() ? cookiesDir.trim() : void 0,
5426
+ cookiesFromBrowser,
4814
5427
  downloadDir: downloadDir.trim() ? downloadDir.trim() : void 0,
4815
5428
  writeMetadata,
4816
5429
  defaultLimit
@@ -4818,7 +5431,15 @@ function SettingsView({
4818
5431
  saveConfig(nextConfig);
4819
5432
  setSaved(true);
4820
5433
  onSave();
4821
- }, [cookiesDir, downloadDir, writeMetadata, defaultLimit, onSave]);
5434
+ }, [cookiesDir, cookiesFromBrowser, downloadDir, writeMetadata, defaultLimit, onSave]);
5435
+ const browserFeedback = useMemo2(() => {
5436
+ if (!cookiesFromBrowser || cookiesFromBrowser === "none") {
5437
+ return "manual cookie files only";
5438
+ }
5439
+ const found = detectedBrowsers.find((b) => b.id === cookiesFromBrowser);
5440
+ const browserName = found ? found.name : cookiesFromBrowser;
5441
+ return `live session cookies from ${browserName}`;
5442
+ }, [cookiesFromBrowser, detectedBrowsers]);
4822
5443
  const cookieFeedback = useMemo2(() => {
4823
5444
  const dir = cookiesDir.trim();
4824
5445
  if (!dir) {
@@ -4829,7 +5450,7 @@ function SettingsView({
4829
5450
  };
4830
5451
  }
4831
5452
  const expanded = expandDir(dir);
4832
- if (!fs11.existsSync(expanded)) {
5453
+ if (!fs15.existsSync(expanded)) {
4833
5454
  return {
4834
5455
  ok: false,
4835
5456
  text: "directory does not exist yet"
@@ -4852,14 +5473,20 @@ function SettingsView({
4852
5473
  if (!dir) {
4853
5474
  return "default: platform Downloads / Open Nami";
4854
5475
  }
4855
- return `target: ${path11.resolve(expandDir(dir))}`;
5476
+ return `target: ${path15.resolve(expandDir(dir))}`;
4856
5477
  }, [downloadDir]);
4857
5478
  const cycleFocus = useCallback((forward = true) => {
4858
- const fields = ["cookies", "download", "metadata", "limit"];
5479
+ const fields = ["cookies", "browser", "download", "metadata", "limit"];
4859
5480
  const idx = fields.indexOf(focusedField);
4860
5481
  const nextIdx = forward ? (idx + 1) % fields.length : (idx - 1 + fields.length) % fields.length;
4861
5482
  setField(fields[nextIdx]);
4862
5483
  }, [focusedField, setField]);
5484
+ const cycleBrowser = useCallback((forward = true) => {
5485
+ setCookiesFromBrowser((prev) => {
5486
+ const next = cycleBrowserOption(prev ?? "none", browserOptions, forward);
5487
+ return next;
5488
+ });
5489
+ }, [browserOptions]);
4863
5490
  const cycleLimit = useCallback(() => {
4864
5491
  setDefaultLimit((prev) => {
4865
5492
  const idx = LIMIT_OPTIONS.indexOf(prev);
@@ -4876,6 +5503,20 @@ function SettingsView({
4876
5503
  handleSave();
4877
5504
  return;
4878
5505
  }
5506
+ if (focusedField === "browser") {
5507
+ if (input === " " || key.return) {
5508
+ cycleBrowser(true);
5509
+ return;
5510
+ }
5511
+ if (key.tab || key.downArrow) {
5512
+ cycleFocus(true);
5513
+ return;
5514
+ }
5515
+ if (key.upArrow) {
5516
+ cycleFocus(false);
5517
+ return;
5518
+ }
5519
+ }
4879
5520
  if (focusedField === "metadata") {
4880
5521
  if (input === " " || key.return) {
4881
5522
  setWriteMetadata((prev) => !prev);
@@ -4916,7 +5557,7 @@ function SettingsView({
4916
5557
  {
4917
5558
  value: cookiesDir,
4918
5559
  onChange: setCookiesDir,
4919
- onSubmit: () => setField("download"),
5560
+ onSubmit: () => setField("browser"),
4920
5561
  focus: focusedField === "cookies",
4921
5562
  onTab: () => cycleFocus(true),
4922
5563
  onArrowDown: () => cycleFocus(true),
@@ -4929,6 +5570,26 @@ function SettingsView({
4929
5570
  ),
4930
5571
  /* @__PURE__ */ jsx15(Text14, { color: cookieFeedback.ok ? theme.primary : theme.gray, dimColor: !cookieFeedback.ok && theme.dimSecondary, children: cookieFeedback.text }),
4931
5572
  /* @__PURE__ */ jsx15(Gap2, {}),
5573
+ /* @__PURE__ */ jsxs12(
5574
+ Box12,
5575
+ {
5576
+ width: boxWidth,
5577
+ borderStyle: "round",
5578
+ borderColor: focusedField === "browser" ? theme.primary : theme.gray,
5579
+ borderDimColor: focusedField !== "browser",
5580
+ paddingX: 2,
5581
+ justifyContent: "space-between",
5582
+ children: [
5583
+ /* @__PURE__ */ jsxs12(Text14, { color: focusedField === "browser" ? theme.primary : theme.gray, bold: focusedField === "browser", children: [
5584
+ focusedField === "browser" ? "\u25CF " : "\u25CB ",
5585
+ "Browser Cookie Bridge"
5586
+ ] }),
5587
+ /* @__PURE__ */ jsx15(Text14, { color: cookiesFromBrowser && cookiesFromBrowser !== "none" ? theme.primary : theme.gray, bold: true, children: `[${(cookiesFromBrowser ?? "none").toUpperCase()}]` })
5588
+ ]
5589
+ }
5590
+ ),
5591
+ /* @__PURE__ */ jsx15(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: browserFeedback }),
5592
+ /* @__PURE__ */ jsx15(Gap2, {}),
4932
5593
  /* @__PURE__ */ jsx15(
4933
5594
  FramedInput,
4934
5595
  {
@@ -4990,12 +5651,14 @@ function SettingsView({
4990
5651
  }
4991
5652
  ),
4992
5653
  /* @__PURE__ */ jsx15(Gap2, {}),
5654
+ /* @__PURE__ */ jsx15(DoctorCard, { boxWidth, theme, report: doctorReport }),
5655
+ /* @__PURE__ */ jsx15(Gap2, {}),
4993
5656
  /* @__PURE__ */ jsx15(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: saved ? "\u2713 preferences saved to ~/.open-nami/config.json" : "tab / \u2191\u2193 switch field \xB7 space toggle/cycle \xB7 ^s / \u21B5 save \xB7 esc back" })
4994
5657
  ] });
4995
5658
  }
4996
5659
 
4997
5660
  // src/components/views/watcher-view.tsx
4998
- import { useState as useState7, useEffect as useEffect6, useRef as useRef3, useCallback as useCallback2 } from "react";
5661
+ import { useState as useState7, useEffect as useEffect7, useRef as useRef3, useCallback as useCallback2 } from "react";
4999
5662
  import { Box as Box13, Text as Text15, useInput as useInput3 } from "ink";
5000
5663
  import Spinner2 from "ink-spinner";
5001
5664
 
@@ -5282,9 +5945,9 @@ function startClipboardWatcher(options = {}) {
5282
5945
  }
5283
5946
 
5284
5947
  // src/lib/reveal.ts
5285
- import fs12 from "fs";
5948
+ import fs16 from "fs";
5286
5949
  import { spawn as spawn4 } from "child_process";
5287
- import path12 from "path";
5950
+ import path16 from "path";
5288
5951
  function buildOpenBrowserCommand(url, platform = process.platform) {
5289
5952
  try {
5290
5953
  const parsed = new URL(url.trim());
@@ -5311,12 +5974,12 @@ function openBrowser(url) {
5311
5974
  }
5312
5975
  }
5313
5976
  function getRevealCommand(targetPath, platform = process.platform, isDirectory) {
5314
- const pathModule = platform === "win32" ? path12.win32 : platform === "darwin" ? path12.posix : path12;
5977
+ const pathModule = platform === "win32" ? path16.win32 : platform === "darwin" ? path16.posix : path16;
5315
5978
  const resolved = pathModule.resolve(targetPath);
5316
5979
  const isDir = isDirectory !== void 0 ? isDirectory : (() => {
5317
5980
  try {
5318
- if (fs12.existsSync(resolved)) {
5319
- return fs12.statSync(resolved).isDirectory();
5981
+ if (fs16.existsSync(resolved)) {
5982
+ return fs16.statSync(resolved).isDirectory();
5320
5983
  }
5321
5984
  } catch {
5322
5985
  }
@@ -5364,13 +6027,13 @@ function WatcherView({
5364
6027
  mediaChoiceRef.current = mediaChoice;
5365
6028
  const writeMetadataRef = useRef3(writeMetadata);
5366
6029
  writeMetadataRef.current = writeMetadata;
5367
- useEffect6(() => {
6030
+ useEffect7(() => {
5368
6031
  const pulseTimer = setInterval(() => {
5369
6032
  setPulseTick((t) => t + 1);
5370
6033
  }, 120);
5371
6034
  return () => clearInterval(pulseTimer);
5372
6035
  }, []);
5373
- useEffect6(() => {
6036
+ useEffect7(() => {
5374
6037
  const watcher = startClipboardWatcher({
5375
6038
  choice: mediaChoiceRef.current,
5376
6039
  getChoice: () => mediaChoiceRef.current,
@@ -5444,7 +6107,7 @@ function WatcherView({
5444
6107
  const target = outputDir || resolveDefaultDownloadDir("instagram", "watcher");
5445
6108
  revealInFileManager(target);
5446
6109
  }, [outputDir]);
5447
- useEffect6(() => {
6110
+ useEffect7(() => {
5448
6111
  if (!onRegisterClickTargets) return;
5449
6112
  const targets = [
5450
6113
  { match: "Target:", padX: 1, action: () => {
@@ -5610,10 +6273,10 @@ function WatcherView({
5610
6273
  }
5611
6274
 
5612
6275
  // src/components/views/batch-view.tsx
5613
- import { useState as useState8, useRef as useRef4, useCallback as useCallback3, useEffect as useEffect7 } from "react";
6276
+ import { useState as useState8, useRef as useRef4, useCallback as useCallback3, useEffect as useEffect8 } from "react";
5614
6277
  import { Box as Box14, Text as Text16, useInput as useInput4 } from "ink";
5615
6278
  import Spinner3 from "ink-spinner";
5616
- import fs13 from "fs";
6279
+ import fs17 from "fs";
5617
6280
 
5618
6281
  // src/lib/batch.ts
5619
6282
  import fsPromises5 from "fs/promises";
@@ -5735,7 +6398,7 @@ async function runBatchQueue(options) {
5735
6398
  }
5736
6399
 
5737
6400
  // src/components/views/batch-view.tsx
5738
- import { Fragment as Fragment4, jsx as jsx17, jsxs as jsxs14 } from "react/jsx-runtime";
6401
+ import { Fragment as Fragment3, jsx as jsx17, jsxs as jsxs14 } from "react/jsx-runtime";
5739
6402
  var PLATFORMS = ["instagram", "tiktok", "facebook", "x"];
5740
6403
  var CHOICES2 = ["all", "photos", "videos", "stories", "highlights"];
5741
6404
  var DATE_PRESETS = ["all", "24h", "7d", "30d", "2024", "2023", "2022", "2021", "custom"];
@@ -5820,7 +6483,7 @@ function BatchView({
5820
6483
  setErrorMessage("Please enter a valid batch file path");
5821
6484
  return;
5822
6485
  }
5823
- if (!fs13.existsSync(trimmed)) {
6486
+ if (!fs17.existsSync(trimmed)) {
5824
6487
  setErrorMessage(`File not found: ${trimmed}`);
5825
6488
  return;
5826
6489
  }
@@ -6152,7 +6815,7 @@ function BatchView({
6152
6815
  }
6153
6816
  }
6154
6817
  }, { isActive: Boolean(process.stdin.isTTY) });
6155
- useEffect7(() => {
6818
+ useEffect8(() => {
6156
6819
  if (!onRegisterClickTargets) return;
6157
6820
  const targets = [];
6158
6821
  if (phase === "input") {
@@ -6442,7 +7105,7 @@ function BatchView({
6442
7105
  }
6443
7106
  ) }),
6444
7107
  /* @__PURE__ */ jsx17(Text16, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u21B5 confirm \xB7 esc cancel" })
6445
- ] }) : /* @__PURE__ */ jsxs14(Fragment4, { children: [
7108
+ ] }) : /* @__PURE__ */ jsxs14(Fragment3, { children: [
6446
7109
  /* @__PURE__ */ jsxs14(Box14, { justifyContent: "space-between", width: "100%", children: [
6447
7110
  /* @__PURE__ */ jsxs14(Box14, { width: colWidth, children: [
6448
7111
  /* @__PURE__ */ jsx17(Text16, { color: theme.gray, dimColor: theme.dimSecondary, children: "[p] Platform: " }),
@@ -6653,8 +7316,82 @@ function frameRowSpan(row) {
6653
7316
  return [first + 1, line.trimEnd().length];
6654
7317
  }
6655
7318
 
7319
+ // src/lib/engines/core/use-engine-events.ts
7320
+ import { useState as useState9, useEffect as useEffect9 } from "react";
7321
+ var INITIAL_ENGINE_EVENT_STATE = {
7322
+ isDownloading: false,
7323
+ activeJob: void 0,
7324
+ progress: void 0,
7325
+ lastError: void 0,
7326
+ lastResult: void 0
7327
+ };
7328
+ function subscribeToEngineEvents(bus, onChange) {
7329
+ let state = { ...INITIAL_ENGINE_EVENT_STATE };
7330
+ const onJobStart = (payload) => {
7331
+ state = {
7332
+ ...state,
7333
+ isDownloading: true,
7334
+ activeJob: payload,
7335
+ progress: void 0,
7336
+ lastError: void 0,
7337
+ lastResult: void 0
7338
+ };
7339
+ onChange(state);
7340
+ };
7341
+ const onJobProgress = (payload) => {
7342
+ state = {
7343
+ ...state,
7344
+ isDownloading: true,
7345
+ progress: payload
7346
+ };
7347
+ onChange(state);
7348
+ };
7349
+ const onJobComplete = (payload) => {
7350
+ state = {
7351
+ ...state,
7352
+ isDownloading: false,
7353
+ lastResult: payload.result
7354
+ };
7355
+ onChange(state);
7356
+ };
7357
+ const onJobError = (payload) => {
7358
+ state = {
7359
+ ...state,
7360
+ isDownloading: false,
7361
+ lastError: payload.error
7362
+ };
7363
+ onChange(state);
7364
+ };
7365
+ const unsubStart = bus.on("job:start", onJobStart);
7366
+ const unsubProgress = bus.on("job:progress", onJobProgress);
7367
+ const unsubComplete = bus.on("job:complete", onJobComplete);
7368
+ const unsubError = bus.on("job:error", onJobError);
7369
+ return () => {
7370
+ if (typeof bus.off === "function") {
7371
+ bus.off("job:start", onJobStart);
7372
+ bus.off("job:progress", onJobProgress);
7373
+ bus.off("job:complete", onJobComplete);
7374
+ bus.off("job:error", onJobError);
7375
+ }
7376
+ unsubStart?.();
7377
+ unsubProgress?.();
7378
+ unsubComplete?.();
7379
+ unsubError?.();
7380
+ };
7381
+ }
7382
+ function useEngineEvents(customBus) {
7383
+ const bus = customBus || defaultTaskManager.events;
7384
+ const [state, setState] = useState9(() => ({
7385
+ ...INITIAL_ENGINE_EVENT_STATE
7386
+ }));
7387
+ useEffect9(() => {
7388
+ return subscribeToEngineEvents(bus, setState);
7389
+ }, [bus]);
7390
+ return state;
7391
+ }
7392
+
6656
7393
  // src/app.tsx
6657
- import { Fragment as Fragment5, jsx as jsx18, jsxs as jsxs15 } from "react/jsx-runtime";
7394
+ import { Fragment as Fragment4, jsx as jsx18, jsxs as jsxs15 } from "react/jsx-runtime";
6658
7395
  var TAGLINE = "grab any profile. paste. download. done.";
6659
7396
  var DOWNLOAD_BUTTON = "download";
6660
7397
  var Gap = ({ lines = 1 }) => /* @__PURE__ */ jsx18(Box15, { flexDirection: "column", flexShrink: 0, children: Array.from({ length: lines }, (_, i) => /* @__PURE__ */ jsx18(Text17, { children: " " }, i)) });
@@ -6732,7 +7469,7 @@ function App({
6732
7469
  writeMetadata,
6733
7470
  version = "1.0.0"
6734
7471
  }) {
6735
- const [themeMode, setThemeMode] = useState9(initialThemeMode);
7472
+ const [themeMode, setThemeMode] = useState10(initialThemeMode);
6736
7473
  const cycleTheme = useCallback4(() => {
6737
7474
  setThemeMode(nextThemeMode);
6738
7475
  }, []);
@@ -6767,18 +7504,19 @@ function InnerApp({
6767
7504
  const theme = useTheme();
6768
7505
  const { exit } = useApp();
6769
7506
  const { stdout } = useStdout3();
7507
+ const engineEvents = useEngineEvents();
6770
7508
  const config = useMemo3(() => loadConfig(), []);
6771
7509
  const effectiveLimit = limit ?? config.defaultLimit;
6772
7510
  const effectiveWriteMetadata = writeMetadata ?? Boolean(config.writeMetadata);
6773
- const [urlInput, setUrlInput] = useState9(initialInput ?? "");
6774
- const [clipboardUrl, setClipboardUrl] = useState9(void 0);
6775
- const [settingsFocus, setSettingsFocus] = useState9("cookies");
6776
- const [batchClickTargets, setBatchClickTargets] = useState9([]);
6777
- const [watcherClickTargets, setWatcherClickTargets] = useState9([]);
7511
+ const [urlInput, setUrlInput] = useState10(initialInput ?? "");
7512
+ const [clipboardUrl, setClipboardUrl] = useState10(void 0);
7513
+ const [settingsFocus, setSettingsFocus] = useState10("cookies");
7514
+ const [batchClickTargets, setBatchClickTargets] = useState10([]);
7515
+ const [watcherClickTargets, setWatcherClickTargets] = useState10([]);
6778
7516
  const columns = stdout?.columns && stdout.columns > 0 ? stdout.columns : 80;
6779
7517
  const boxWidth = Math.max(14, Math.min(64, columns - 6));
6780
7518
  const contentWidth = Math.max(10, Math.min(columns - 4, 78));
6781
- const [phase, setPhase] = useState9(() => {
7519
+ const [phase, setPhase] = useState10(() => {
6782
7520
  if (initialInput) {
6783
7521
  const parsed = parseProfileInput(initialInput);
6784
7522
  if (parsed.platform !== "unknown" && parsed.cleanUrl) {
@@ -6790,7 +7528,7 @@ function InnerApp({
6790
7528
  }
6791
7529
  return { name: "input" };
6792
7530
  });
6793
- useEffect8(() => {
7531
+ useEffect10(() => {
6794
7532
  try {
6795
7533
  const clip = readClipboard();
6796
7534
  if (clip && (clip.startsWith("http://") || clip.startsWith("https://") || clip.startsWith("@"))) {
@@ -6803,7 +7541,7 @@ function InnerApp({
6803
7541
  const clipboardAccepted = Boolean(clipboardUrl) && urlInput === clipboardUrl;
6804
7542
  const abortControllerRef = useRef5(null);
6805
7543
  const probingTimeoutRef = useRef5(null);
6806
- useEffect8(() => {
7544
+ useEffect10(() => {
6807
7545
  return () => {
6808
7546
  abortControllerRef.current?.abort();
6809
7547
  if (probingTimeoutRef.current) {
@@ -7163,7 +7901,7 @@ function InnerApp({
7163
7901
  {
7164
7902
  profile: phase.profile,
7165
7903
  choice: phase.choice,
7166
- progress: phase.progress,
7904
+ progress: engineEvents.progress ?? phase.progress,
7167
7905
  outputDir: resolveDefaultDownloadDir(phase.profile.platform, phase.profile.username, outputDir),
7168
7906
  writeMetadata: effectiveWriteMetadata,
7169
7907
  limit: effectiveLimit,
@@ -7229,7 +7967,7 @@ function InnerApp({
7229
7967
  onRegisterClickTargets: setBatchClickTargets
7230
7968
  }
7231
7969
  ),
7232
- hints.length > 0 ? /* @__PURE__ */ jsxs15(Fragment5, { children: [
7970
+ hints.length > 0 ? /* @__PURE__ */ jsxs15(Fragment4, { children: [
7233
7971
  /* @__PURE__ */ jsx18(Gap, { lines: 2 }),
7234
7972
  /* @__PURE__ */ jsx18(
7235
7973
  Shortcuts,
@@ -7443,7 +8181,7 @@ import { jsx as jsx19 } from "react/jsx-runtime";
7443
8181
  var resolveVersion = () => {
7444
8182
  try {
7445
8183
  if (typeof import.meta !== "undefined" && import.meta?.url) {
7446
- return createRequire(import.meta.url)("../package.json").version;
8184
+ return createRequire2(import.meta.url)("../package.json").version;
7447
8185
  }
7448
8186
  } catch {
7449
8187
  }
@@ -7467,6 +8205,7 @@ var HELP = `
7467
8205
  Options:
7468
8206
  -h, --help Show this help message
7469
8207
  -v, --version Show version
8208
+ --doctor Run environment & dependency diagnostics
7470
8209
  -o, --output Set custom output directory
7471
8210
  -w, --watch Watch clipboard for profile links and auto-download
7472
8211
  -b, --batch <file> Download multiple profiles listed in a text file (one URL/user per line)
@@ -7513,6 +8252,24 @@ async function main() {
7513
8252
  console.log(`open-nami v${VERSION}`);
7514
8253
  process.exit(0);
7515
8254
  }
8255
+ if (args.includes("--doctor")) {
8256
+ const report = await runDoctor();
8257
+ console.log(`
8258
+ \x1B[36mOpen Nami Doctor\x1B[0m (v${VERSION})
8259
+ `);
8260
+ console.log(` \x1B[1mRuntime:\x1B[0m Node.js ${report.nodeVersion}`);
8261
+ console.log(` \x1B[1mStorage:\x1B[0m ${report.storageOk ? "\x1B[32m\u2713 Ready\x1B[0m" : "\x1B[31m\u2717 Inaccessible\x1B[0m"}`);
8262
+ console.log(` \x1B[1mEngines:\x1B[0m`);
8263
+ for (const [bin, ok] of Object.entries(report.binaries)) {
8264
+ console.log(` ${ok ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m"} ${bin}`);
8265
+ }
8266
+ console.log(` \x1B[1mCookies:\x1B[0m`);
8267
+ for (const [platform, ok] of Object.entries(report.cookies)) {
8268
+ console.log(` ${ok ? "\x1B[32m\u2713\x1B[0m" : "\x1B[90m\u25CB\x1B[0m"} ${platform}`);
8269
+ }
8270
+ console.log();
8271
+ process.exit(0);
8272
+ }
7516
8273
  const completionIdx = args.findIndex((arg) => arg === "--completion" || arg.startsWith("--completion="));
7517
8274
  if (completionIdx !== -1) {
7518
8275
  const arg = args[completionIdx];