├── src-tauri ├── build.rs ├── src │ ├── exporters │ │ ├── mod.rs │ │ ├── exporter.rs │ │ └── resources │ │ │ ├── style.css │ │ │ └── example.html │ ├── app │ │ ├── mod.rs │ │ ├── progress.rs │ │ ├── error.rs │ │ ├── export_type.rs │ │ ├── sanitizers.rs │ │ ├── converter.rs │ │ ├── attachment_manager.rs │ │ ├── options.rs │ │ └── runtime.rs │ ├── main.rs │ └── imessage.rs ├── .gitignore ├── icons │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 32x32.png │ ├── icon.icns │ ├── StoreLogo.png │ ├── app-icon.png │ ├── 128x128@2x.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ └── Square89x89Logo.png ├── Cargo.toml └── tauri.conf.json ├── .vscode └── settings.json ├── public ├── banner.webp ├── instructions.webp ├── vercel.svg └── next.svg ├── postcss.config.js ├── next.config.js ├── app ├── chat │ └── page.tsx ├── layout.tsx ├── globals.css └── page.tsx ├── util ├── getDataDir.ts ├── identifyPlatform.ts ├── openNewWindow.ts ├── trimTextPretty.ts └── dataTypes.ts ├── .gitignore ├── tailwind.config.js ├── tsconfig.json ├── README.md ├── package.json ├── mocks └── fakeMessages.ts └── components └── DiskAccessDialog.tsx /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/src/exporters/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod exporter; 2 | pub mod html; 3 | pub mod txt; 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.linkedProjects": ["./src-tauri/Cargo.toml"] 3 | } 4 | -------------------------------------------------------------------------------- /public/banner.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/public/banner.webp -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | -------------------------------------------------------------------------------- /public/instructions.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/public/instructions.webp -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/app-icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hunterunger/imessage_exporter_app/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: 'export', 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /src-tauri/src/app/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod attachment_manager; 2 | pub mod converter; 3 | pub mod error; 4 | pub mod export_type; 5 | pub mod options; 6 | pub mod progress; 7 | pub mod runtime; 8 | pub mod sanitizers; 9 | -------------------------------------------------------------------------------- /app/chat/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | export default function Home() { 6 | return
Hello
; 7 | } 8 | -------------------------------------------------------------------------------- /util/getDataDir.ts: -------------------------------------------------------------------------------- 1 | export default async function getDataDir() { 2 | try { 3 | const dataDir = (await import("@tauri-apps/api/path")).appDataDir; 4 | 5 | const dir = await dataDir(); 6 | 7 | return dir; 8 | } catch (error) { 9 | console.log(error); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /util/identifyPlatform.ts: -------------------------------------------------------------------------------- 1 | export default async function getPlatform() { 2 | try { 3 | const platform = (await import("@tauri-apps/api/os")).platform; 4 | 5 | const osName = await platform(); 6 | 7 | return osName; 8 | } catch (error) { 9 | console.log(error); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /util/openNewWindow.ts: -------------------------------------------------------------------------------- 1 | export default async function openNewWindow(label: string, url: string) { 2 | try { 3 | const WebviewWindow = (await import("@tauri-apps/api/window")) 4 | .WebviewWindow; 5 | new WebviewWindow(label, { url: url }); 6 | } catch (error) { 7 | console.log(error); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | .env -------------------------------------------------------------------------------- /util/trimTextPretty.ts: -------------------------------------------------------------------------------- 1 | export default function trimTextPretty( 2 | text: string, 3 | maxLength: number, 4 | fromStart: boolean = false 5 | ): string { 6 | if (text.length <= maxLength) { 7 | return text; 8 | } 9 | 10 | const ellipsis = "..."; 11 | const lengthWithoutEllipsis = maxLength - ellipsis.length; 12 | 13 | if (fromStart) { 14 | return ellipsis + text.slice(text.length - lengthWithoutEllipsis); 15 | } 16 | 17 | return text.slice(0, lengthWithoutEllipsis) + ellipsis; 18 | } 19 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './app/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 12 | 'gradient-conic': 13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { appDataDir } from "@tauri-apps/api/path"; 2 | import "./globals.css"; 3 | import { Inter } from "next/font/google"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata = { 8 | title: "macOs App", 9 | description: "", 10 | }; 11 | 12 | export default async function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/app/progress.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use indicatif::{ProgressBar, ProgressStyle}; 4 | 5 | pub fn build_progress_bar_export(total_messages: u64) -> ProgressBar { 6 | let pb = ProgressBar::new(total_messages); 7 | pb.set_style( 8 | ProgressStyle::default_bar() 9 | .template( 10 | "{spinner:.green} [{elapsed}] [{bar:.blue}] {pos}/{len} ({per_sec}, ETA: {eta})", 11 | ) 12 | .unwrap() 13 | .progress_chars("#>-"), 14 | ); 15 | pb.set_position(0); 16 | pb.enable_steady_tick(Duration::from_millis(100)); 17 | pb 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iMessage JSON Exporter App 2 | 3 | ![Banner](/public/banner.webp) 4 | 5 | Export your message to json. Automatic database detection, or manual chat.db selection. Intended for macOS, but works on Windows 11 and Linux as well. 6 | 7 | ![Banner](/public/instructions.webp) 8 | 9 | ## How to export iMessage messages to json: 10 | 11 | 1. Download prebuilt app, or build it yourself. 12 | 2. Launch. Press the load button. 13 | 3. Your messages should show up. 14 | 4. Select the chat group you want to export. Click the share button and select an export directory. 15 | 16 | Done! 17 | 18 | ### Built with... 19 | 20 | [iMessage Database](https://github.com/ReagentX/imessage-exporter) 21 | 22 | [Tauri](https://github.com/tauri-apps/tauri) 23 | 24 | [Next.js](https://nextjs.org) 25 | 26 | [Heroicons](https://heroicons.com) 27 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | user-select: none; 10 | user-zoom: fixed; 11 | } 12 | 13 | @media (prefers-color-scheme: dark) { 14 | :root { 15 | --foreground-rgb: 255, 255, 255; 16 | --background-start-rgb: 0, 0, 0; 17 | --background-end-rgb: 0, 0, 0; 18 | } 19 | } 20 | 21 | body { 22 | color: rgb(var(--foreground-rgb)); 23 | background: linear-gradient( 24 | to bottom, 25 | transparent, 26 | rgb(var(--background-end-rgb)) 27 | ) 28 | rgb(var(--background-start-rgb)); 29 | font-family: "SF Pro Display", "SF Pro Icons", "Helvetica Neue", "Helvetica", 30 | sans-serif; 31 | } 32 | 33 | div { 34 | cursor: default; 35 | } 36 | -------------------------------------------------------------------------------- /util/dataTypes.ts: -------------------------------------------------------------------------------- 1 | export type MessageType = { 2 | chat_identifier: string; 3 | associated_message_guid: null; 4 | associated_message_type: number; 5 | balloon_bundle_id: null; 6 | chat_id: number | null; 7 | date: string; 8 | date_delivered: number; 9 | date_edited: number; 10 | date_read: number; 11 | deleted_from: null; 12 | expressive_send_style_id: null; 13 | group_action_type: number; 14 | group_title: string | null; 15 | guid: string; 16 | handle_id: number; 17 | is_from_me: boolean; 18 | is_read: boolean; 19 | item_type: number; 20 | num_attachments: number; 21 | num_replies: number; 22 | rowid: number; 23 | service: string; 24 | subject: null; 25 | text: string; 26 | thread_originator_guid: null; 27 | thread_originator_part: null; 28 | }; 29 | 30 | export type MessageGroupType = { 31 | chat_id: number; 32 | messages: MessageType[]; 33 | chat_type: "Individual" | "Group"; 34 | address: string | number; 35 | }; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imessage_export_app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@heroicons/react": "^2.1.1", 14 | "@tauri-apps/api": "^1.5.3", 15 | "@types/node": "20.2.5", 16 | "@types/react": "18.2.8", 17 | "@types/react-dom": "18.2.4", 18 | "autoprefixer": "10.4.14", 19 | "moment": "^2.29.4", 20 | "next": "^14.1.0", 21 | "postcss": "8.4.24", 22 | "react": "18.2.0", 23 | "react-datepicker": "^4.14.0", 24 | "react-dom": "18.2.0", 25 | "tailwindcss": "3.3.2", 26 | "typescript": "5.1.3" 27 | }, 28 | "devDependencies": { 29 | "@tauri-apps/cli": "^1.5.9", 30 | "@types/react-datepicker": "^4.11.2", 31 | "eslint": "8.43.0", 32 | "eslint-config-next": "13.4.6" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | license = "" 7 | repository = "" 8 | default-run = "app" 9 | edition = "2021" 10 | rust-version = "1.60" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [build-dependencies] 15 | tauri-build = { version = "1.3.0", features = [] } 16 | 17 | [dependencies] 18 | serde_json = "1.0" 19 | serde = { version = "1.0", features = ["derive"] } 20 | tauri = { version = "1.3.0", features = [ "fs-create-dir", "fs-copy-file", "fs-exists", "fs-remove-dir", "path-all", "os-all", "shell-all", "dialog-all", "fs-read-dir", "fs-read-file", "fs-write-file", "window-create"] } 21 | # sqlite = "0.30.4" 22 | imessage-database = "1.4.0" 23 | chrono = "0.4.26" 24 | 25 | clap = { version = "4.4.8", features = ["cargo"] } 26 | filetime = "0.2.22" 27 | fs2 = "0.4.3" 28 | indicatif = "0.17.7" 29 | rusqlite = { version = "0.30.0", features = ["blob", "bundled"] } 30 | uuid = { version = "1.5.0", features = ["v4", "fast-rng"] } 31 | fs_extra = "1.3.0" 32 | 33 | 34 | 35 | [features] 36 | # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. 37 | # If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes. 38 | # DO NOT REMOVE!! 39 | custom-protocol = ["tauri/custom-protocol"] 40 | -------------------------------------------------------------------------------- /src-tauri/src/app/error.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | Errors that can happen during the application's runtime 3 | */ 4 | 5 | use std::{ 6 | fmt::{Display, Formatter, Result}, 7 | io::Error as IoError, 8 | }; 9 | 10 | use imessage_database::{error::table::TableError, util::size::format_file_size}; 11 | 12 | use crate::app::options::OPTION_BYPASS_FREE_SPACE_CHECK; 13 | 14 | /// Errors that can happen during the application's runtime 15 | #[derive(Debug)] 16 | pub enum RuntimeError { 17 | InvalidOptions(String), 18 | DiskError(IoError), 19 | DatabaseError(TableError), 20 | NotEnoughAvailableSpace(u64, u64), 21 | } 22 | 23 | impl Display for RuntimeError { 24 | fn fmt(&self, fmt: &mut Formatter<'_>) -> Result { 25 | match self { 26 | RuntimeError::InvalidOptions(why) => write!(fmt, "Invalid options!\n{why}"), 27 | RuntimeError::DiskError(why) => write!(fmt, "{why}"), 28 | RuntimeError::DatabaseError(why) => write!(fmt, "{why}"), 29 | RuntimeError::NotEnoughAvailableSpace(estimated_bytes, available_bytes) => { 30 | write!( 31 | fmt, 32 | "Not enough free disk space!\nEstimated export size: {}\nDisk space available: {}\nPass `--{}` to ignore\n", 33 | format_file_size(*estimated_bytes), 34 | format_file_size(*available_bytes), 35 | OPTION_BYPASS_FREE_SPACE_CHECK 36 | ) 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src-tauri/src/app/export_type.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | Contains data structures used to describe export types. 3 | */ 4 | 5 | use std::fmt::Display; 6 | 7 | /// Represents the type of file to export iMessage data into 8 | #[derive(PartialEq, Eq, Debug)] 9 | pub enum ExportType { 10 | /// HTML file export 11 | Html, 12 | /// Text file export 13 | Txt, 14 | } 15 | 16 | impl ExportType { 17 | /// Given user's input, return a variant if the input matches one 18 | pub fn from_cli(platform: &str) -> Option { 19 | match platform.to_lowercase().as_str() { 20 | "txt" => Some(Self::Txt), 21 | "html" => Some(Self::Html), 22 | _ => None, 23 | } 24 | } 25 | } 26 | 27 | impl Display for ExportType { 28 | fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 | match self { 30 | ExportType::Txt => write!(fmt, "txt"), 31 | ExportType::Html => write!(fmt, "html"), 32 | } 33 | } 34 | } 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | use crate::app::export_type::ExportType; 39 | 40 | #[test] 41 | fn can_parse_html_any_case() { 42 | assert!(matches!( 43 | ExportType::from_cli("html"), 44 | Some(ExportType::Html) 45 | )); 46 | assert!(matches!( 47 | ExportType::from_cli("HTML"), 48 | Some(ExportType::Html) 49 | )); 50 | assert!(matches!( 51 | ExportType::from_cli("HtMl"), 52 | Some(ExportType::Html) 53 | )); 54 | } 55 | 56 | #[test] 57 | fn can_parse_txt_any_case() { 58 | assert!(matches!(ExportType::from_cli("txt"), Some(ExportType::Txt))); 59 | assert!(matches!(ExportType::from_cli("TXT"), Some(ExportType::Txt))); 60 | assert!(matches!(ExportType::from_cli("tXt"), Some(ExportType::Txt))); 61 | } 62 | 63 | #[test] 64 | fn cant_parse_invalid() { 65 | assert!(ExportType::from_cli("pdf").is_none()); 66 | assert!(ExportType::from_cli("json").is_none()); 67 | assert!(ExportType::from_cli("").is_none()); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /mocks/fakeMessages.ts: -------------------------------------------------------------------------------- 1 | import { MessageGroupType, MessageType } from "@/util/dataTypes"; 2 | 3 | const fakeMessageBase: MessageType = { 4 | associated_message_guid: null, 5 | associated_message_type: 0, 6 | balloon_bundle_id: null, 7 | chat_id: 0, 8 | date: "2023-06-13 12:12:12", 9 | date_delivered: 0, 10 | date_edited: 0, 11 | date_read: 708325325148700000, 12 | deleted_from: null, 13 | expressive_send_style_id: null, 14 | group_action_type: 0, 15 | group_title: null, 16 | guid: "ABCDEF-123456-ABCDEF-123456", 17 | handle_id: 1, 18 | is_from_me: false, 19 | is_read: true, 20 | item_type: 0, 21 | num_attachments: 0, 22 | num_replies: 0, 23 | rowid: 0, 24 | service: "iMessage", 25 | subject: null, 26 | text: "Who’s free Friday for a hike?", 27 | thread_originator_guid: null, 28 | thread_originator_part: null, 29 | chat_identifier: "Fake Group Chat", 30 | }; 31 | 32 | const fakeMessageGroupBase: MessageGroupType = { 33 | address: 0, 34 | messages: [], 35 | chat_type: "Group", 36 | chat_id: 0, 37 | }; 38 | 39 | const fakeConversation: { 40 | is_from_me: boolean; 41 | text: string; 42 | }[] = [ 43 | { 44 | is_from_me: false, 45 | text: "Who’s free Friday for a hike?", 46 | }, 47 | { 48 | is_from_me: true, 49 | text: "I’m free! 👍", 50 | }, 51 | { 52 | is_from_me: false, 53 | text: "I’m free too!", 54 | }, 55 | { 56 | is_from_me: true, 57 | text: "Awesome! Does 10am work for everyone?", 58 | }, 59 | { 60 | is_from_me: false, 61 | text: "Sounds good to me!", 62 | }, 63 | { 64 | is_from_me: true, 65 | text: "I’ll bring the snacks.", 66 | }, 67 | { 68 | is_from_me: false, 69 | text: "Hopefully it doesn’t rain. The forecast says it might... :(", 70 | }, 71 | { 72 | is_from_me: true, 73 | text: "I’ll bring a raincoat just in case.", 74 | }, 75 | ]; 76 | 77 | export function generateFakeMessageGroup() { 78 | // compile fake message group 79 | const fakeMessages = []; 80 | 81 | for (let i = 0; i < fakeConversation.length; i++) { 82 | const fakeMessage = { 83 | ...fakeMessageBase, 84 | ...fakeConversation[i], 85 | }; 86 | fakeMessages.push(fakeMessage); 87 | } 88 | 89 | const fakeMessageGroup: MessageGroupType = { 90 | ...fakeMessageGroupBase, 91 | messages: fakeMessages, 92 | }; 93 | 94 | return fakeMessageGroup; 95 | } 96 | -------------------------------------------------------------------------------- /src-tauri/src/app/sanitizers.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | /// The character to replace disallowed chars with 4 | const FILENAME_REPLACEMENT_CHAR: char = '_'; 5 | /// Characters disallowed in a filename 6 | const FILENAME_DISALLOWED_CHARS: [char; 3] = ['/', '\\', ':']; 7 | 8 | /// Remove unsafe chars in [this list](FILENAME_DISALLOWED_CHARS). 9 | pub fn sanitize_filename(filename: &str) -> String { 10 | filename 11 | .chars() 12 | .map(|letter| { 13 | if FILENAME_DISALLOWED_CHARS.contains(&letter) { 14 | FILENAME_REPLACEMENT_CHAR 15 | } else { 16 | letter 17 | } 18 | }) 19 | .collect() 20 | } 21 | 22 | /// Escapes HTML special characters in the input string. 23 | pub fn sanitize_html(input: &str) -> Cow { 24 | for (idx, char) in input.char_indices() { 25 | if matches!(char, '<' | '>' | '"' | '’' | '&') { 26 | let mut res = String::from(&input[..idx]); 27 | input[idx..].chars().for_each(|c| match c { 28 | '<' => res.push_str("<"), 29 | '>' => res.push_str(">"), 30 | '"' => res.push_str("""), 31 | '’' => res.push_str("'"), 32 | '&' => res.push_str("&"), 33 | _ => res.push(c), 34 | }); 35 | return Cow::Owned(res); 36 | } 37 | } 38 | Cow::Borrowed(input) 39 | } 40 | 41 | #[cfg(test)] 42 | mod test_filename { 43 | use crate::app::sanitizers::sanitize_filename; 44 | 45 | #[test] 46 | fn can_sanitize_all() { 47 | assert_eq!(sanitize_filename("a/b\\c:d"), "a_b_c_d"); 48 | } 49 | 50 | #[test] 51 | fn doesnt_sanitize_none() { 52 | assert_eq!(sanitize_filename("a_b_c_d"), "a_b_c_d"); 53 | } 54 | 55 | #[test] 56 | fn can_sanitize_one() { 57 | assert_eq!(sanitize_filename("ab/cd"), "ab_cd"); 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use crate::app::sanitizers::sanitize_html; 64 | 65 | #[test] 66 | fn test_escape_html_chars_basic() { 67 | assert_eq!( 68 | &sanitize_html("

Hello, world > HTML

"), 69 | "<p>Hello, world > HTML</p>" 70 | ); 71 | } 72 | 73 | #[test] 74 | fn doesnt_sanitize_empty_string() { 75 | assert_eq!(&sanitize_html(""), ""); 76 | } 77 | 78 | #[test] 79 | fn doesnt_sanitize_no_special_chars() { 80 | assert_eq!(&sanitize_html("Hello world"), "Hello world"); 81 | } 82 | 83 | #[test] 84 | fn can_sanitize_all_special_chars() { 85 | assert_eq!(&sanitize_html("<>&\"’"), "<>&"'"); 86 | } 87 | 88 | #[test] 89 | fn can_sanitize_mixed_content() { 90 | assert_eq!( 91 | &sanitize_html("
Hello & world
"), 92 | "<div>Hello &amp; world</div>" 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json", 3 | "build": { 4 | "beforeBuildCommand": "turbo build", 5 | "beforeDevCommand": "turbo dev", 6 | "devPath": "http://localhost:3000", 7 | "distDir": "../out" 8 | }, 9 | "package": { 10 | "productName": "iMessage Exporter App", 11 | "version": "0.1.0" 12 | }, 13 | "tauri": { 14 | "allowlist": { 15 | "path": { 16 | "all": true 17 | }, 18 | "window": { 19 | "create": true 20 | }, 21 | "all": false, 22 | "dialog": { 23 | "all": true 24 | }, 25 | "fs": { 26 | "all": false, 27 | "copyFile": true, 28 | "createDir": true, 29 | "exists": true, 30 | "readDir": true, 31 | "readFile": true, 32 | "removeDir": true, 33 | "removeFile": false, 34 | "renameFile": false, 35 | "scope": ["$APPDATA/*"], 36 | "writeFile": true 37 | }, 38 | "shell": { 39 | "all": true, 40 | "execute": false, 41 | "open": "^x-apple.*", 42 | "scope": [], 43 | "sidecar": false 44 | }, 45 | "os": { 46 | "all": true 47 | } 48 | }, 49 | "bundle": { 50 | "active": true, 51 | "category": "DeveloperTool", 52 | "copyright": "", 53 | "deb": { 54 | "depends": [] 55 | }, 56 | "externalBin": [], 57 | "icon": [ 58 | "icons/32x32.png", 59 | "icons/128x128.png", 60 | "icons/128x128@2x.png", 61 | "icons/icon.icns", 62 | "icons/icon.ico" 63 | ], 64 | "identifier": "com.imessage-export-app.oxen.dev", 65 | "longDescription": "", 66 | "macOS": { 67 | "entitlements": null, 68 | "exceptionDomain": "", 69 | "frameworks": [], 70 | "providerShortName": null, 71 | "signingIdentity": null 72 | }, 73 | "resources": [], 74 | "shortDescription": "", 75 | "targets": "all", 76 | "windows": { 77 | "certificateThumbprint": null, 78 | "digestAlgorithm": "sha256", 79 | "timestampUrl": "" 80 | } 81 | }, 82 | "security": { 83 | "csp": null 84 | }, 85 | "updater": { 86 | "active": false 87 | }, 88 | "windows": [ 89 | { 90 | "fullscreen": false, 91 | "height": 600, 92 | "resizable": true, 93 | "title": "iMessage Export", 94 | "width": 800, 95 | "titleBarStyle": "Transparent", 96 | "minHeight": 400, 97 | "minWidth": 500 98 | } 99 | ] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /components/DiskAccessDialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useState } from "react"; 3 | import { invoke } from "@tauri-apps/api/tauri"; 4 | import { open } from "@tauri-apps/api/shell"; 5 | import { ArrowsRightLeftIcon, CogIcon } from "@heroicons/react/24/outline"; 6 | import getPlatform from "./../util/identifyPlatform"; 7 | 8 | export default function DiskAccessDialog(props: { 9 | setPermissionSuccess: (value: boolean) => void; 10 | }) { 11 | /* 12 | Checks the disk access status. If disk access is not granted, it will prompt the how to grant disk access through macOS settings. 13 | */ 14 | 15 | const [diskAccessStatus, setDiskAccessStatus] = useState(); 16 | const [os, setOs] = useState(""); 17 | 18 | const checkDiskAccess = async () => { 19 | let os = await getPlatform(); 20 | 21 | if (os && os != "darwin") { 22 | setDiskAccessStatus(true); 23 | setOs(os); 24 | props.setPermissionSuccess(false); 25 | return; 26 | } 27 | 28 | const diskAccessStatus = await invoke("test_disk_permission"); 29 | setDiskAccessStatus(diskAccessStatus); 30 | props.setPermissionSuccess(diskAccessStatus); 31 | }; 32 | 33 | useEffect(() => { 34 | checkDiskAccess(); 35 | }, []); 36 | 37 | return ( 38 |
39 | {diskAccessStatus === false && ( 40 |
41 |

42 | {`You need to grant full disk access to the app in order 43 | to automatically detect and read the iMessage database.`} 44 |

45 | 56 | 66 |
67 | )} 68 | 69 | {os != "darwin" && os != "" && ( 70 |
71 |

72 | {`Automatic detection of iMessage database is only supported macOS. You can still manually select a database file.`} 73 |

74 |
75 | )} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src-tauri/src/exporters/exporter.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use imessage_database::{ 4 | error::{message::MessageError, plist::PlistParseError, table::TableError}, 5 | message_types::{ 6 | app::AppMessage, app_store::AppStoreMessage, collaboration::CollaborationMessage, 7 | handwriting::HandwrittenMessage, music::MusicMessage, placemark::PlacemarkMessage, 8 | url::URLMessage, 9 | }, 10 | tables::{attachment::Attachment, messages::Message}, 11 | }; 12 | 13 | use crate::app::{error::RuntimeError, runtime::Config}; 14 | 15 | /// Defines behavior for iterating over messages from the iMessage database and managing export files 16 | pub trait Exporter<'a> { 17 | /// Create a new exporter with references to the cached data 18 | fn new(config: &'a Config) -> Self; 19 | /// Begin iterating over the messages table 20 | fn iter_messages(&mut self) -> Result<(), RuntimeError>; 21 | /// Get the file handle to write to, otherwise create a new one 22 | fn get_or_create_file(&mut self, message: &Message) -> &Path; 23 | } 24 | 25 | /// Defines behavior for formatting message instances to the desired output format 26 | pub(super) trait Writer<'a> { 27 | /// Format a message, including its reactions and replies 28 | fn format_message(&self, msg: &Message, indent: usize) -> Result; 29 | /// Format an attachment, possibly by reading the disk 30 | fn format_attachment( 31 | &self, 32 | attachment: &'a mut Attachment, 33 | msg: &'a Message, 34 | ) -> Result; 35 | /// Format a sticker, possibly by reading the disk 36 | fn format_sticker(&self, attachment: &'a mut Attachment, msg: &'a Message) -> String; 37 | /// Format an app message by parsing some of its fields 38 | fn format_app( 39 | &self, 40 | msg: &'a Message, 41 | attachments: &mut Vec, 42 | indent: &str, 43 | ) -> Result; 44 | /// Format a reaction (displayed under a message) 45 | fn format_reaction(&self, msg: &Message) -> Result; 46 | /// Format an expressive message 47 | fn format_expressive(&self, msg: &'a Message) -> &'a str; 48 | /// Format an announcement message 49 | fn format_announcement(&self, msg: &'a Message) -> String; 50 | /// Format a `SharePlay` message 51 | fn format_shareplay(&self) -> &str; 52 | /// Format an edited message 53 | fn format_edited(&self, msg: &'a Message, indent: &str) -> Result; 54 | fn write_to_file(file: &Path, text: &str); 55 | } 56 | 57 | /// Defines behavior for formatting custom balloons to the desired output format 58 | pub(super) trait BalloonFormatter { 59 | /// Format a URL message 60 | fn format_url(&self, balloon: &URLMessage, indent: T) -> String; 61 | /// Format an Apple Music message 62 | fn format_music(&self, balloon: &MusicMessage, indent: T) -> String; 63 | /// Format a Rich Collaboration message 64 | fn format_collaboration(&self, balloon: &CollaborationMessage, indent: T) -> String; 65 | /// Format an App Store link 66 | fn format_app_store(&self, balloon: &AppStoreMessage, indent: T) -> String; 67 | /// Format a shared location message 68 | fn format_placemark(&self, balloon: &PlacemarkMessage, indent: T) -> String; 69 | /// Format a handwritten note message 70 | fn format_handwriting(&self, balloon: &HandwrittenMessage, indent: T) -> String; 71 | /// Format an Apple Pay message 72 | fn format_apple_pay(&self, balloon: &AppMessage, indent: T) -> String; 73 | /// Format a Fitness message 74 | fn format_fitness(&self, balloon: &AppMessage, indent: T) -> String; 75 | /// Format a Photo Slideshow message 76 | fn format_slideshow(&self, balloon: &AppMessage, indent: T) -> String; 77 | /// Format a Find My message 78 | fn format_find_my(&self, balloon: &AppMessage, indent: T) -> String; 79 | /// Format a Check In message 80 | fn format_check_in(&self, balloon: &AppMessage, indent: T) -> String; 81 | /// Format a generic app, generally third party 82 | fn format_generic_app( 83 | &self, 84 | balloon: &AppMessage, 85 | bundle_id: &str, 86 | attachments: &mut Vec, 87 | indent: T, 88 | ) -> String; 89 | } 90 | -------------------------------------------------------------------------------- /src-tauri/src/app/converter.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::create_dir_all, 3 | path::Path, 4 | process::{Command, Stdio}, 5 | }; 6 | 7 | #[derive(Debug)] 8 | pub enum ImageType { 9 | #[allow(non_camel_case_types)] 10 | Jpeg, 11 | #[allow(non_camel_case_types)] 12 | Gif, 13 | #[allow(non_camel_case_types)] 14 | Png, 15 | } 16 | 17 | impl ImageType { 18 | pub fn to_str(&self) -> &'static str { 19 | match self { 20 | ImageType::Jpeg => "jpeg", 21 | ImageType::Gif => "gif", 22 | ImageType::Png => "png", 23 | } 24 | } 25 | } 26 | 27 | #[derive(Debug)] 28 | pub enum Converter { 29 | Sips, 30 | Imagemagick, 31 | } 32 | 33 | impl Converter { 34 | /// Determine the converter type for the current shell environment 35 | pub fn determine() -> Option { 36 | if exists("sips") { 37 | return Some(Converter::Sips); 38 | } 39 | if exists("convert") { 40 | return Some(Converter::Imagemagick); 41 | } 42 | eprintln!("No HEIC converter found, attachments will not be converted!"); 43 | None 44 | } 45 | } 46 | 47 | /// Determine if a shell program exists on the system 48 | fn exists(name: &str) -> bool { 49 | if let Ok(process) = Command::new("type") 50 | .args(&vec![name]) 51 | .stdout(Stdio::null()) 52 | .stderr(Stdio::null()) 53 | .stdin(Stdio::null()) 54 | .spawn() 55 | { 56 | if let Ok(output) = process.wait_with_output() { 57 | return output.status.success(); 58 | } 59 | }; 60 | false 61 | } 62 | 63 | /// Convert a HEIC image file to the provided format 64 | /// 65 | /// This uses the macOS builtin `sips` program 66 | /// Docs: (or `man sips`) 67 | /// 68 | /// If `to` contains a directory that does not exist, i.e. `/fake/out.jpg`, instead 69 | /// of failing, `sips` will create a file called `fake` in `/`. Subsequent writes 70 | /// by `sips` to the same location will not fail, but since it is a file instead 71 | /// of a directory, this will fail for non-`sips` copies. 72 | pub fn convert_heic( 73 | from: &Path, 74 | to: &Path, 75 | converter: &Converter, 76 | output_image_type: &ImageType, 77 | ) -> Option<()> { 78 | // Get the path we want to copy from 79 | let from_path = from.to_str()?; 80 | 81 | // Get the path we want to write to 82 | let to_path = to.to_str()?; 83 | 84 | // Ensure the directory tree exists 85 | if let Some(folder) = to.parent() { 86 | if !folder.exists() { 87 | if let Err(why) = create_dir_all(folder) { 88 | eprintln!("Unable to create {folder:?}: {why}"); 89 | return None; 90 | } 91 | } 92 | } 93 | 94 | match converter { 95 | Converter::Sips => { 96 | // Build the command 97 | match Command::new("sips") 98 | .args(&vec![ 99 | "-s", 100 | "format", 101 | output_image_type.to_str(), 102 | from_path, 103 | "-o", 104 | to_path, 105 | ]) 106 | .stdout(Stdio::null()) 107 | .stderr(Stdio::null()) 108 | .stdin(Stdio::null()) 109 | .spawn() 110 | { 111 | Ok(mut sips) => match sips.wait() { 112 | Ok(_) => Some(()), 113 | Err(why) => { 114 | eprintln!("Conversion failed: {why}"); 115 | None 116 | } 117 | }, 118 | Err(why) => { 119 | eprintln!("Conversion failed: {why}"); 120 | None 121 | } 122 | } 123 | } 124 | Converter::Imagemagick => { 125 | // Build the command 126 | match Command::new("convert") 127 | .args(&vec![from_path, to_path]) 128 | .stdout(Stdio::null()) 129 | .stderr(Stdio::null()) 130 | .stdin(Stdio::null()) 131 | .spawn() 132 | { 133 | Ok(mut convert) => match convert.wait() { 134 | Ok(_) => Some(()), 135 | Err(why) => { 136 | eprintln!("Conversion failed: {why}"); 137 | None 138 | } 139 | }, 140 | Err(why) => { 141 | eprintln!("Conversion failed: {why}"); 142 | None 143 | } 144 | } 145 | } 146 | }; 147 | 148 | Some(()) 149 | } 150 | 151 | #[cfg(test)] 152 | mod test { 153 | use super::exists; 154 | 155 | #[test] 156 | fn can_find_program() { 157 | assert!(exists("ls")); 158 | } 159 | 160 | #[test] 161 | fn can_miss_program() { 162 | assert!(!exists("fake_name")); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | mod app; 5 | mod exporters; 6 | 7 | pub use exporters::{exporter::Exporter, html::HTML, txt::TXT}; 8 | 9 | use app::{ 10 | options::{get_command, Options}, 11 | runtime::Config, 12 | }; 13 | 14 | mod imessage; 15 | use crate::imessage::get_messages_json; 16 | use imessage_database::util::dirs::default_db_path; 17 | use std; 18 | use std::fs; 19 | 20 | #[tauri::command] 21 | fn get_messages(custompath: &str, fromdate: &str, todate: &str) -> String { 22 | /* 23 | Open the chat.db file, and read the messages table, converting it to a JSON string, and returning it to the frontend. 24 | */ 25 | 26 | // default path to chat.db 27 | 28 | // if the user has specified a custom path, use that instead 29 | 30 | let path; 31 | 32 | if custompath != "" { 33 | path = custompath.to_string(); 34 | } else { 35 | let default_path = default_db_path(); 36 | 37 | path = default_path.to_str().unwrap().to_string(); 38 | }; 39 | 40 | // run the query 41 | let result = get_messages_json(&path, fromdate, todate); 42 | 43 | // ensure result is a string 44 | let result = result.unwrap(); 45 | 46 | // return the JSON string to the frontend 47 | result 48 | } 49 | 50 | #[tauri::command] 51 | fn test_disk_permission() -> bool { 52 | /* 53 | Test if the user has permission to read the chat.db file. 54 | */ 55 | 56 | // default path to chat.db 57 | let secured_path = default_db_path(); 58 | 59 | // try to open the file 60 | let file_exists = fs::File::open(&secured_path).is_ok(); 61 | 62 | // return true if the app has permission to read the file 63 | file_exists 64 | } 65 | 66 | #[tauri::command] 67 | fn html_export(custompath: &str, customoutput: &str, fromdate: &str, todate: &str) -> Vec { 68 | let path; 69 | 70 | if custompath != "" { 71 | path = custompath.to_string(); 72 | } else { 73 | let default_path = default_db_path(); 74 | 75 | path = default_path.to_str().unwrap().to_string(); 76 | }; 77 | 78 | // Get args from command line 79 | let cli_args: Vec<&str> = vec![ 80 | "imessage-exporter", 81 | "-f", 82 | "html", 83 | "-c", 84 | "compatible", 85 | "-p", 86 | path.as_str(), 87 | "-o", 88 | customoutput, 89 | "--start-date", 90 | fromdate, 91 | "--end-date", 92 | todate, 93 | "--no-lazy", 94 | ]; 95 | let command = get_command(); 96 | let args = command.get_matches_from(cli_args); 97 | 98 | // Create application options 99 | let options = Options::from_args(&args); 100 | 101 | // Create app state and start 102 | if let Err(why) = &options { 103 | eprintln!("{why}"); 104 | } else { 105 | match Config::new(options.unwrap()) { 106 | Ok(app) => { 107 | if let Err(why) = app.start() { 108 | eprintln!("Unable to start: {why}"); 109 | } 110 | } 111 | Err(why) => { 112 | eprintln!("Unable to launch: {why}"); 113 | } 114 | } 115 | } 116 | 117 | // list all the files in the output directory 118 | let output = std::fs::read_dir(customoutput).unwrap(); 119 | 120 | // create an empty vector to store the file names 121 | let mut files = Vec::new(); 122 | 123 | // iterate over the files in the output directory 124 | for file in output { 125 | // get the file name 126 | let file = file.unwrap().file_name().into_string().unwrap(); 127 | 128 | // add the file name to the vector 129 | files.push(file); 130 | } 131 | 132 | files.retain(|x| x != "orphaned"); 133 | 134 | // return the file names to the frontend 135 | files 136 | } 137 | 138 | #[tauri::command] 139 | fn run_shell_command(command: &str) -> String { 140 | /* 141 | Run a shell command and return the output. 142 | */ 143 | 144 | // run the command 145 | let output = std::process::Command::new("sh") 146 | .arg("-c") 147 | .arg(command) 148 | .output() 149 | .expect("failed to execute sh"); 150 | 151 | // convert the output to a string 152 | let output = String::from_utf8_lossy(&output.stdout).to_string(); 153 | 154 | // return the output 155 | output 156 | } 157 | 158 | #[tauri::command] 159 | fn copy_dir(src: &str, dest: &str) -> bool { 160 | /* 161 | Copy a directory from src to dest. 162 | */ 163 | 164 | // copy the directory 165 | let result = fs_extra::dir::copy(src, dest, &fs_extra::dir::CopyOptions::new()); 166 | 167 | // return true if the directory was copied successfully 168 | result.is_ok() 169 | } 170 | 171 | fn main() { 172 | // let quit = CustomMenuItem::new("quit".to_string(), "Quit"); 173 | // let close = CustomMenuItem::new("close".to_string(), "Close"); 174 | // let submenu = Submenu::new("File", Menu::new().add_item(quit).add_item(close)); 175 | // let menu = Menu::new() 176 | // .add_native_item(MenuItem::Copy) 177 | // .add_item(CustomMenuItem::new("hide", "Hide")) 178 | // .add_submenu(submenu); 179 | 180 | tauri::Builder::default() 181 | // .menu(menu) 182 | .invoke_handler(tauri::generate_handler![ 183 | get_messages, 184 | test_disk_permission, 185 | run_shell_command, 186 | html_export, 187 | copy_dir 188 | ]) 189 | .run(tauri::generate_context!()) 190 | .expect("error while running tauri application"); 191 | } 192 | -------------------------------------------------------------------------------- /src-tauri/src/imessage.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Duration, NaiveDateTime}; 2 | use imessage_database::tables::chat::Chat; 3 | use imessage_database::tables::table::Cacheable; 4 | use imessage_database::util::query_context::QueryContext; 5 | use imessage_database::{ 6 | error::table::TableError, 7 | tables::{ 8 | chat_handle::ChatToHandle, 9 | messages::Message, 10 | table::{get_connection, Table}, 11 | }, 12 | }; 13 | use serde_json; 14 | use serde_json::json; 15 | use std::collections::HashMap; 16 | use std::path::Path; 17 | 18 | use crate::app::error::RuntimeError; 19 | 20 | /* 21 | 22 | { rowid: 31502, guid: "65FEFB7C-F112-4E54-B65B-F82C8BA1E67B", text: Some("Just leaving town "), service: Some("iMessage"), handle_id: 1, subject: None, date: 442436468000000000, date_read: 442436480000000000, date_delivered: 442436480000000000, is_from_me: false, is_read: true, item_type: 0, group_title: None, group_action_type: 0, associated_message_guid: None, associated_message_type: Some(0), balloon_bundle_id: None, expressive_send_style_id: None, thread_originator_guid: None, thread_originator_part: None, date_edited: 0, chat_id: Some(1), num_attachments: 0, deleted_from: None, num_replies: 0 } 23 | 24 | */ 25 | 26 | pub fn get_messages_json( 27 | path: &str, 28 | min_date_str: &str, 29 | max_date_str: &str, 30 | ) -> Result { 31 | /* 32 | path: &str - the path to the iMessage database 33 | min_date_str: &str - the minimum date to get messages from in the format "YYYY-MM-DD HH:MM:SS" 34 | max_date_str: &str - the maximum date to get messages from in the format "YYYY-MM-DD HH:MM:SS" 35 | */ 36 | 37 | // Create a read-only connection to an iMessage database 38 | let db_path = Path::new(path); 39 | let db = get_connection(db_path).unwrap(); 40 | 41 | // 42 | let mut query_context = QueryContext::default(); 43 | query_context.set_start(min_date_str); 44 | query_context.set_end(max_date_str); 45 | 46 | // Create SQL statement 47 | let mut statement = Message::stream_rows(&db, &query_context).map_err(|e| { 48 | println!("Error: {:?}", e); 49 | e 50 | })?; 51 | 52 | // get count of messages 53 | let total_messages = Message::get_count(&db, &query_context).map_err(|e| { 54 | println!("Error: {:?}", e); 55 | e 56 | })?; 57 | 58 | println!("Total Messages: {}", total_messages); 59 | 60 | // Execute statement 61 | let messages = statement 62 | .query_map([], |row| Ok(Message::from_row(row))) 63 | .unwrap(); 64 | 65 | // create an empty vector to store the messages in a hashmap 66 | let mut messages_vec = Vec::new(); 67 | 68 | let chatrooms: HashMap = Chat::cache(&db).unwrap(); 69 | 70 | for message in messages { 71 | let mut msg = Message::extract(message)?; 72 | msg.gen_text(&db); 73 | 74 | // convert the Unix timestamp to a NaiveDateTime object 75 | let date = msg.date; 76 | let unix_epoch = NaiveDateTime::from_timestamp(0, 0); 77 | let date_time = 78 | unix_epoch + Duration::seconds(date / 1_000_000_000) + Duration::seconds(978_307_200); 79 | 80 | // convert the NaiveDateTime object to a string 81 | let date_str = date_time.to_string(); 82 | 83 | // get the chat_identifier from the chatrooms hashmap 84 | let chat_identifier = match chatrooms.get(&msg.chat_id.or(Some(0)).unwrap()) { 85 | Some(chat) => chat.chat_identifier.clone(), 86 | None => "Unknown".to_string(), 87 | }; 88 | 89 | // create empty hashmap to store the message. 90 | let message_json = json!({ 91 | "chat_identifier": chat_identifier, 92 | 93 | "rowid": msg.rowid, 94 | "guid": msg.guid, 95 | "text": msg.text, 96 | "service": msg.service, 97 | "handle_id": msg.handle_id, 98 | "subject": msg.subject, 99 | "date": date_str, 100 | "date_read": msg.date_read, 101 | "date_delivered": msg.date_delivered, 102 | "is_from_me": msg.is_from_me, 103 | "is_read": msg.is_read, 104 | "item_type": msg.item_type, 105 | "group_title": msg.group_title, 106 | "group_action_type": msg.group_action_type, 107 | "associated_message_guid": msg.associated_message_guid, 108 | "associated_message_type": msg.associated_message_type, 109 | "balloon_bundle_id": msg.balloon_bundle_id, 110 | "expressive_send_style_id": msg.expressive_send_style_id, 111 | "thread_originator_guid": msg.thread_originator_guid, 112 | "thread_originator_part": msg.thread_originator_part, 113 | "date_edited": msg.date_edited, 114 | "chat_id": msg.chat_id, 115 | "num_attachments": msg.num_attachments, 116 | "deleted_from": msg.deleted_from, 117 | "num_replies": msg.num_replies, 118 | }); 119 | 120 | // insert the hashmap into the vector 121 | messages_vec.push(message_json); 122 | } 123 | 124 | // reverse the vector so that the messages are in chronological order 125 | messages_vec.reverse(); 126 | 127 | if total_messages > 1500 { 128 | // warn and cut off the messages 129 | println!("Warning: More than 1500 messages. Only the last 1500 messages will be saved."); 130 | messages_vec.truncate(1500); 131 | } 132 | 133 | // convert the Vec to a json string 134 | let json_string = serde_json::to_string(&messages_vec).unwrap(); 135 | 136 | // to make a pretty json string 137 | // let json_string = serde_json::to_string_pretty(&messages_vec).unwrap(); 138 | 139 | // return the json string 140 | Ok(json_string) 141 | } 142 | 143 | fn main() { 144 | // get the messages as a json string 145 | let messages_json = get_messages_json( 146 | "/Users/hunterunger/Library/Messages/chat.db", 147 | "2021-01-01 00:00:00", 148 | "2021-01-31 23:59:59", 149 | ) 150 | .unwrap(); 151 | 152 | // save the json string to a file 153 | std::fs::write("messages.json", messages_json); 154 | } 155 | -------------------------------------------------------------------------------- /src-tauri/src/exporters/resources/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 3 | } 4 | 5 | p { 6 | margin: 0px; 7 | } 8 | 9 | xmp { 10 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 11 | white-space: pre-wrap; 12 | margin: 0px; 13 | } 14 | 15 | a[href^="#"] { 16 | text-decoration: none; 17 | color: darkblue; 18 | } 19 | 20 | .message { 21 | margin: 1%; 22 | overflow-wrap: break-word; 23 | } 24 | 25 | .message .sent.iMessage { 26 | background-color: #1982FC; 27 | } 28 | 29 | .message .sent.sms { 30 | background-color: #65c466 31 | } 32 | 33 | .message .sent { 34 | color: white; 35 | border-radius: 25px; 36 | padding: 15px; 37 | margin-left: auto; 38 | margin-right: 0; 39 | max-width: 60%; 40 | width: fit-content; 41 | } 42 | 43 | .message .received { 44 | background-color: #d8d8d8; 45 | color: black; 46 | border-radius: 25px; 47 | padding: 15px; 48 | margin-right: auto; 49 | margin-left: 0; 50 | max-width: 60%; 51 | width: fit-content; 52 | } 53 | 54 | .message .sent .replies .reply .message .sent { 55 | border-style: solid; 56 | border-color: white; 57 | border-width: thin; 58 | } 59 | 60 | .message .received .replies .reply .message .received { 61 | border-style: solid; 62 | border-color: darkgray; 63 | border-width: thin; 64 | } 65 | 66 | .message .received .replies { 67 | border-left: dotted dimgray; 68 | border-bottom: dotted dimgray; 69 | border-bottom-left-radius: 25px; 70 | } 71 | 72 | .message .sent .replies { 73 | border-left: dotted white; 74 | border-bottom: dotted white; 75 | border-bottom-left-radius: 25px; 76 | } 77 | 78 | .received .replies { 79 | margin-top: 1%; 80 | padding-left: 1%; 81 | padding-right: 1%; 82 | } 83 | 84 | .sent .replies { 85 | margin-top: 1%; 86 | padding-left: 1%; 87 | padding-right: 1%; 88 | } 89 | 90 | .reply .received { 91 | max-width: 85%; 92 | padding: 15px; 93 | } 94 | 95 | .reply .sent { 96 | max-width: 85%; 97 | padding: 15px; 98 | } 99 | 100 | .app { 101 | background: white; 102 | border-radius: 25px; 103 | } 104 | 105 | .app a { 106 | text-decoration: none; 107 | } 108 | 109 | .app_header { 110 | border-top-left-radius: 25px; 111 | border-top-right-radius: 25px; 112 | color: black; 113 | } 114 | 115 | 116 | .app_header img { 117 | border-top-left-radius: 25px; 118 | border-top-right-radius: 25px; 119 | margin-left: auto; 120 | margin-right: auto; 121 | width: 100%; 122 | } 123 | 124 | .app_header audio { 125 | padding-bottom: 2%; 126 | } 127 | 128 | 129 | .app_header .image_title { 130 | padding-top: 1%; 131 | padding-bottom: 1%; 132 | padding-left: 15px; 133 | padding-right: 15px; 134 | overflow: auto; 135 | } 136 | 137 | 138 | .app_header .image_subtitle { 139 | padding-top: 1%; 140 | padding-bottom: 1%; 141 | padding-left: 15px; 142 | padding-right: 15px; 143 | overflow: auto; 144 | } 145 | 146 | .app_header .ldtext { 147 | padding-top: 1%; 148 | padding-bottom: 1%; 149 | padding-left: 15px; 150 | padding-right: 15px; 151 | overflow: auto; 152 | } 153 | 154 | .app_header .name { 155 | color: black; 156 | font-weight: 600; 157 | padding-top: 1%; 158 | padding-bottom: 1%; 159 | padding-left: 15px; 160 | padding-right: 15px; 161 | overflow: auto; 162 | } 163 | 164 | .app_footer { 165 | display: grid; 166 | grid-template-areas: 167 | 'caption trailing_caption' 168 | 'subcaption trailing_subcaption'; 169 | border-bottom-left-radius: 25px; 170 | border-bottom-right-radius: 25px; 171 | 172 | border-bottom-style: solid; 173 | border-bottom-color: darkgray; 174 | 175 | border-left-style: solid; 176 | border-left-color: darkgray; 177 | 178 | border-right-style: solid; 179 | border-right-color: darkgray; 180 | 181 | border-width: thin; 182 | color: black; 183 | background: lightgray; 184 | padding-bottom: 1%; 185 | } 186 | 187 | .app_footer .caption { 188 | grid-area: caption; 189 | margin-top: 1%; 190 | padding-left: 15px; 191 | padding-right: 15px; 192 | overflow: auto; 193 | } 194 | 195 | .app_footer .subcaption { 196 | grid-area: subcaption; 197 | margin-top: 1%; 198 | padding-left: 15px; 199 | padding-right: 15px; 200 | overflow: auto; 201 | } 202 | 203 | .app_footer .trailing_caption { 204 | grid-area: trailing_caption; 205 | text-align: right; 206 | margin-top: 1%; 207 | padding-left: 15px; 208 | padding-right: 15px; 209 | overflow: auto; 210 | } 211 | 212 | .app_footer .trailing_subcaption { 213 | grid-area: trailing_subcaption; 214 | text-align: right; 215 | margin-top: 1%; 216 | padding-left: 15px; 217 | padding-right: 15px; 218 | overflow: auto; 219 | } 220 | 221 | span.timestamp { 222 | opacity: 60%; 223 | } 224 | 225 | span.reply_anchor { 226 | opacity: 100%; 227 | } 228 | 229 | span.sender { 230 | opacity: 100%; 231 | } 232 | 233 | span.deleted { 234 | opacity: 60%; 235 | } 236 | 237 | span.subject { 238 | font-weight: 600; 239 | } 240 | 241 | span.bubble { 242 | white-space: pre-wrap; 243 | overflow-wrap: break-word; 244 | } 245 | 246 | span.reply_context { 247 | opacity: 60%; 248 | } 249 | 250 | span.expressive { 251 | opacity: 60%; 252 | } 253 | 254 | span.reactions { 255 | opacity: 60%; 256 | } 257 | 258 | div.reactions img { 259 | max-width: 5em; 260 | } 261 | 262 | div.reaction { 263 | display: flex; 264 | align-items: center; 265 | } 266 | 267 | div.sticker_effect { 268 | opacity: 60%; 269 | } 270 | 271 | div.sticker img { 272 | max-width: 5em; 273 | } 274 | 275 | .announcement { 276 | text-align: center; 277 | padding: 2vh 1vw 2vh 1vw; 278 | word-wrap: break-word; 279 | } 280 | 281 | img { 282 | max-width: 100%; 283 | max-height: 90vh; 284 | } 285 | 286 | video { 287 | max-width: 100%; 288 | max-height: 90vh; 289 | } 290 | 291 | audio { 292 | width: 90%; 293 | margin-left: auto; 294 | margin-right: auto; 295 | display: block; 296 | } 297 | 298 | .sent table { 299 | color: white; 300 | } 301 | 302 | .received table { 303 | color: black; 304 | } 305 | 306 | .received .sent table { 307 | color: white; 308 | } 309 | 310 | table { 311 | border-collapse: collapse; 312 | text-align: left; 313 | } 314 | 315 | thead { 316 | border-bottom: 2px solid white; 317 | } 318 | 319 | td { 320 | padding: 2px 5px; 321 | } 322 | 323 | .sent tbody { 324 | color: rgba(256, 256, 256, 0.7) 325 | } 326 | 327 | .received .sent tbody { 328 | color: rgba(256, 256, 256, 0.7) 329 | } 330 | 331 | .received tbody { 332 | color: rgba(0, 0, 0, 0.7) 333 | } 334 | 335 | .received .announcement { 336 | color: black; 337 | } 338 | 339 | .sent .announcement { 340 | color: white; 341 | } 342 | 343 | @media (prefers-color-scheme: dark) { 344 | body { 345 | background: black; 346 | } 347 | 348 | .announcement { 349 | color: lightgray; 350 | } 351 | } 352 | 353 | @media (prefers-color-scheme: light) { 354 | body { 355 | background: transparent; 356 | } 357 | } -------------------------------------------------------------------------------- /src-tauri/src/app/attachment_manager.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | fs::{copy, create_dir_all, metadata}, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use filetime::{set_file_times, FileTime}; 8 | use imessage_database::tables::{attachment::Attachment, messages::Message}; 9 | use uuid::Uuid; 10 | 11 | use crate::app::{ 12 | converter::{convert_heic, Converter, ImageType}, 13 | runtime::Config, 14 | }; 15 | 16 | /// Represents different ways the app can interact with attachment data 17 | #[derive(Debug, PartialEq, Eq)] 18 | pub enum AttachmentManager { 19 | /// Do not copy attachments 20 | Disabled, 21 | /// Copy and convert attachments to more compatible formats using a [`Converter`] 22 | Compatible, 23 | /// Copy attachments without converting; preserves quality but may not display correctly in all browsers 24 | Efficient, 25 | } 26 | 27 | impl AttachmentManager { 28 | /// Create an instance of the enum given user input 29 | pub fn from_cli(copy_state: &str) -> Option { 30 | match copy_state.to_lowercase().as_str() { 31 | "compatible" => Some(Self::Compatible), 32 | "efficient" => Some(Self::Efficient), 33 | "disabled" => Some(Self::Disabled), 34 | _ => None, 35 | } 36 | } 37 | 38 | /// Handle an attachment, copying and converting if requested 39 | /// 40 | /// If copied, update attachment's `copied_path` 41 | pub fn handle_attachment<'a>( 42 | &'a self, 43 | message: &Message, 44 | attachment: &'a mut Attachment, 45 | config: &Config, 46 | ) -> Option<()> { 47 | // Resolve the path to the attachment 48 | let attachment_path = attachment.resolved_attachment_path( 49 | &config.options.platform, 50 | &config.options.db_path, 51 | config.options.attachment_root.as_deref(), 52 | )?; 53 | 54 | if !matches!(self, AttachmentManager::Disabled) { 55 | let from = Path::new(&attachment_path); 56 | 57 | // Ensure the file exists at the specified location 58 | if !from.exists() { 59 | eprintln!("Attachment not found at specified path: {from:?}"); 60 | return None; 61 | } 62 | 63 | // Create a path to copy the file to 64 | let mut to = config.attachment_path(); 65 | 66 | // Add the subdirectory 67 | let sub_dir = config.conversation_attachment_path(message.chat_id); 68 | to.push(sub_dir); 69 | 70 | // Add a random filename 71 | to.push(Uuid::new_v4().to_string()); 72 | 73 | // Set the new file's extension to the original one 74 | to.set_extension(attachment.extension()?); 75 | 76 | match self { 77 | AttachmentManager::Compatible => match &config.converter { 78 | Some(converter) => { 79 | Self::copy_convert(from, &mut to, converter, attachment.is_sticker); 80 | } 81 | None => Self::copy_raw(from, &to), 82 | }, 83 | AttachmentManager::Efficient => Self::copy_raw(from, &to), 84 | AttachmentManager::Disabled => unreachable!(), 85 | }; 86 | 87 | // Update file metadata 88 | if let Ok(metadata) = metadata(from) { 89 | let mtime = match &message.date(&config.offset) { 90 | Ok(date) => { 91 | FileTime::from_unix_time(date.timestamp(), date.timestamp_subsec_nanos()) 92 | } 93 | Err(_) => FileTime::from_last_modification_time(&metadata), 94 | }; 95 | 96 | let atime = FileTime::from_last_access_time(&metadata); 97 | 98 | if let Err(why) = set_file_times(&to, atime, mtime) { 99 | eprintln!("Unable to update {to:?} metadata: {why}"); 100 | } 101 | } 102 | attachment.copied_path = Some(to); 103 | } 104 | Some(()) 105 | } 106 | 107 | /// Copy a file without altering it 108 | fn copy_raw(from: &Path, to: &Path) { 109 | // Ensure the directory tree exists 110 | if let Some(folder) = to.parent() { 111 | if !folder.exists() { 112 | if let Err(why) = create_dir_all(folder) { 113 | eprintln!("Unable to create {folder:?}: {why}"); 114 | } 115 | } 116 | } 117 | if let Err(why) = copy(from, to) { 118 | eprintln!("Unable to copy {from:?} to {to:?}: {why}"); 119 | }; 120 | } 121 | 122 | /// Copy a file, converting if possible 123 | /// 124 | /// - Sticker `HEIC` files convert to `PNG` 125 | /// - Sticker `HEICS` files convert to `GIF` 126 | /// - Attachment `HEIC` files convert to `JPEG` 127 | /// - Other files are copied with their original formats 128 | fn copy_convert(from: &Path, to: &mut PathBuf, converter: &Converter, is_sticker: bool) { 129 | let original_extension = from.extension().unwrap_or_default(); 130 | 131 | // Handle sticker attachments 132 | if is_sticker { 133 | // Determine the output type of the sticker 134 | let output_type: Option = match original_extension.to_str() { 135 | // Normal stickers get converted to png 136 | Some("heic" | "HEIC") => Some(ImageType::Png), 137 | // Animated stickers get converted to gif 138 | Some("heics" | "HEICS") => Some(ImageType::Gif), 139 | _ => None, 140 | }; 141 | 142 | match output_type { 143 | Some(output_type) => { 144 | to.set_extension(output_type.to_str()); 145 | if convert_heic(from, to, converter, &output_type).is_none() { 146 | eprintln!("Unable to convert {from:?}"); 147 | } 148 | } 149 | None => Self::copy_raw(from, to), 150 | } 151 | } 152 | // Normal attachments always get converted to jpeg 153 | else if original_extension == "heic" || original_extension == "HEIC" { 154 | let output_type = ImageType::Jpeg; 155 | // Update extension for conversion 156 | to.set_extension(output_type.to_str()); 157 | if convert_heic(from, to, converter, &output_type).is_none() { 158 | eprintln!("Unable to convert {from:?}"); 159 | } 160 | } else { 161 | Self::copy_raw(from, to); 162 | } 163 | } 164 | } 165 | 166 | impl Default for AttachmentManager { 167 | fn default() -> Self { 168 | Self::Disabled 169 | } 170 | } 171 | 172 | impl Display for AttachmentManager { 173 | fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 174 | match self { 175 | AttachmentManager::Disabled => write!(fmt, "disabled"), 176 | AttachmentManager::Compatible => write!(fmt, "compatible"), 177 | AttachmentManager::Efficient => write!(fmt, "efficient"), 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src-tauri/src/exporters/resources/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |

Jul 17, 2022 5:04:04 PM 9 | Me 10 |

11 | Subject: iMessage Data Exporter 12 |
13 |
Hey, did you see that new iMessage Rust project? You can export your message history to portable formats!
14 |
15 |
16 |

Reactions:

17 |
Questioned by +15558675309
18 |
19 |
20 |
21 | 22 |
23 | 42 |
43 | 44 |
45 |

Jul 17, 2022 5:04:50 PM You renamed the conversation to 🦀 iMessage Exporter 🦀

46 |
47 | 48 |
49 |
50 |

Jul 17, 2022 5:31:49 PM (Read by you after 5 seconds) 51 | steve@icloud.com 52 |

53 |
54 |
No, does it support threads? I have a lot of important messages that I have been meaning to back up.
55 | 56 |
57 |
58 |
59 |
60 |

Jul 17, 2022 5:32:03 PM 61 | Me 62 |

63 |
64 |
Yep! It supports all iMessage features: stickers, reactions, expressives... you name it, you can export it!
65 |
66 |
67 |

Reactions:

68 |
from Me
70 |
Loved by +15558675309
71 |
72 |
73 |
74 |
75 | 76 |
77 |
78 |
79 |

Jul 17, 2022 5:35:03 PM (Read by you after 1 minute, 3 seconds) 80 | +15558675309 81 |

82 |
83 |
Even those pesky HEIC files? Nothing can display those!
84 |
85 |
86 |

Reactions:

87 |
Liked by Me
88 |
89 |
90 |
91 |
92 | 93 |
94 |
95 |
96 |

Jul 17, 2022 5:37:53 PM 97 | Me 98 |

99 |
100 |
Yes, even those! They get converted to JPEG.
101 |
102 |
103 |

Reactions:

104 |
Emphasized by steve@icloud.com
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | 115 |
116 |
117 |

Jul 17, 2022 5:40:03 PM (Read by you after 3 minutes, 1 second) 118 | +15558675309 119 |

120 |
121 |
What about group chats? Sometimes the iMessage app shows multiple conversations with the same people.
122 |
123 |
124 |

Reactions:

125 |
Disliked by Me
126 |
127 |
128 |
129 | 130 |
131 |
132 |

Jul 17, 2022 5:44:25 PM 133 | Me 134 |

135 |
136 |
Yes, this tool will deduplicate both contacts and conversations to ensure messages go to the right place!
137 |
138 |
139 |

Reactions:

140 |
Liked by steve@icloud.com
141 |
Loved by +15558675309
142 |
143 |
144 |
145 |
146 |
147 |

Jul 17, 2022 5:45:03 PM 148 | +15558675309 149 |

150 |
151 |
152 |
153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 |
That's amazin
Edited 9 seconds laterThat's amazing!
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 | 176 |
177 |
178 |

Jul 17, 2022 5:47:37 PM 179 | Me 180 |

181 |
182 |
Hang on guys, I have bad service right now. I hope you find this tool useful!
183 |
184 |
185 | 186 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import DiskAccessDialog from "@/components/DiskAccessDialog"; 4 | import { generateFakeMessageGroup } from "@/mocks/fakeMessages"; 5 | import { MessageGroupType, MessageType } from "@/util/dataTypes"; 6 | import trimTextPretty from "@/util/trimTextPretty"; 7 | import { 8 | ArrowUpOnSquareIcon, 9 | FolderIcon, 10 | PhotoIcon, 11 | PlayIcon, 12 | XCircleIcon, 13 | } from "@heroicons/react/24/outline"; 14 | import { open, save } from "@tauri-apps/api/dialog"; 15 | import { copyFile, exists, writeFile } from "@tauri-apps/api/fs"; 16 | import { invoke } from "@tauri-apps/api/tauri"; 17 | import moment from "moment"; 18 | import { useEffect, useState } from "react"; 19 | import DatePicker from "react-datepicker"; 20 | import "react-datepicker/dist/react-datepicker.css"; 21 | import { removeDir, BaseDirectory } from "@tauri-apps/api/fs"; 22 | import getDataDir from "@/util/getDataDir"; 23 | 24 | export default function Home() { 25 | const [filepath, setFilepath] = useState(""); 26 | const [messagesGroups, setMessageGroups] = useState<{ 27 | [key: string]: MessageGroupType; 28 | }>(); 29 | const [permissionSuccess, setPermissionSuccess] = useState(false); 30 | const [sortBy, setSortBy] = useState<"Date" | "Name">("Date"); 31 | const [filters, setFilters] = useState<{ 32 | dateFrom: string; 33 | dateTo: string; 34 | chatId: string; 35 | }>({ 36 | dateFrom: moment().subtract(1, "week").format("YYYY-MM-DD"), 37 | dateTo: moment().format("YYYY-MM-DD"), 38 | chatId: "", 39 | }); 40 | 41 | const [chatrooms, setChatrooms] = useState(); 42 | 43 | useEffect(() => { 44 | // check if development environment 45 | if (process.env.NODE_ENV === "production") { 46 | document.addEventListener("contextmenu", function (event) { 47 | event.preventDefault(); 48 | }); 49 | } 50 | }, []); 51 | 52 | useEffect(() => { 53 | try { 54 | if (!!navigator && permissionSuccess) { 55 | loadHtmlFiles(); 56 | } 57 | } catch (e) { 58 | console.error(e); 59 | } 60 | }, [permissionSuccess]); 61 | 62 | const loadFile = () => { 63 | if (process.env.NEXT_PUBLIC_SCREENSHOT_MODE === "true") { 64 | setMessageGroups({ 65 | "1": generateFakeMessageGroup(), 66 | }); 67 | return; 68 | } 69 | 70 | invoke("get_messages", { 71 | custompath: filepath, 72 | fromdate: filters.dateFrom, 73 | todate: filters.dateTo, 74 | }).then((data) => { 75 | let parsedData: MessageType[] = JSON.parse(data); 76 | 77 | // group by chat 78 | let groupedData: { [key: string]: MessageGroupType } = {}; 79 | let count = 0; 80 | parsedData.forEach((message: MessageType) => { 81 | if (message.chat_id === null) return; 82 | 83 | if (groupedData[message.chat_id]) { 84 | count++; 85 | groupedData[message.chat_id].messages.push(message); 86 | } else { 87 | groupedData[message.chat_id] = { 88 | chat_id: message.chat_id, 89 | messages: [message], 90 | chat_type: 91 | message.chat_id === 0 ? "Group" : "Individual", 92 | address: message.chat_identifier, 93 | }; 94 | } 95 | }); 96 | 97 | setMessageGroups(groupedData); 98 | }); 99 | }; 100 | 101 | const loadHtmlFiles = async (path?: string) => { 102 | if (!permissionSuccess || typeof window == "undefined") return; 103 | 104 | const outputFolderName = "html_export"; 105 | 106 | const appDataDirPath = await getDataDir(); 107 | 108 | const outputDataExists = await exists(outputFolderName, { 109 | dir: BaseDirectory.AppData, 110 | }); 111 | 112 | if (!path && outputDataExists) { 113 | console.log("removing", outputFolderName); 114 | await removeDir(outputFolderName, { 115 | dir: BaseDirectory.AppData, 116 | recursive: true, 117 | }); 118 | } 119 | 120 | const newChatrooms = await invoke(outputFolderName, { 121 | custompath: "", 122 | customoutput: path 123 | ? path + "/" + outputFolderName 124 | : appDataDirPath + outputFolderName, 125 | fromdate: filters.dateFrom, 126 | todate: filters.dateTo, 127 | }); 128 | 129 | setChatrooms( 130 | newChatrooms 131 | .sort((a, b) => a.toString().localeCompare(b.toString())) 132 | .filter((v) => !["attachments", "orphaned.html"].includes(v)) 133 | ); 134 | }; 135 | 136 | return ( 137 |
138 | 139 |
140 | { 142 | loadHtmlFiles(); 143 | }} 144 | > 145 | 146 | Reload 147 | 148 | { 150 | open({ directory: true }).then((result) => { 151 | console.log(result); 152 | if (typeof result != "string") return; 153 | 154 | loadHtmlFiles(result); 155 | }); 156 | }} 157 | > 158 | 159 | Export All With Attachments 160 | 161 |
162 |
163 | 167 | 168 | Load 169 | 170 | { 172 | open({ 173 | filters: [ 174 | { 175 | name: "Database", 176 | extensions: ["db"], 177 | }, 178 | ], 179 | }).then((result) => { 180 | if (result === undefined) return; 181 | // result is either an array, string, or undefined 182 | if (Array.isArray(result)) { 183 | setFilepath(result[0]); 184 | } else if (typeof result === "string") { 185 | setFilepath(result); 186 | } 187 | }); 188 | }} 189 | > 190 | 191 | Custom 192 | 193 |
194 | {filepath === "" ? ( 195 | <> 196 | ) : ( 197 |
198 | {trimTextPretty(filepath, 30, true)} 199 | { 202 | setFilepath(""); 203 | }} 204 | /> 205 |
206 | )} 207 |
208 |
209 | 213 | setFilters({ 214 | ...filters, 215 | dateFrom: moment(date).format("YYYY-MM-DD"), 216 | }) 217 | } 218 | setEndDate={(date: Date) => 219 | setFilters({ 220 | ...filters, 221 | dateTo: moment(date).format("YYYY-MM-DD"), 222 | }) 223 | } 224 | /> 225 | {chatrooms ? ( 226 |
227 |

Chat Groups

228 |
229 | {chatrooms.map((chatroom) => ( 230 |
234 |

235 | {chatroom 236 | .replace(".html", "") 237 | .replaceAll(", ", "\n")} 238 |

239 | { 241 | save({ 242 | defaultPath: chatroom, 243 | filters: [ 244 | { 245 | name: "HTML", 246 | extensions: ["html"], 247 | }, 248 | ], 249 | }).then(async (filepath) => { 250 | if (!filepath) return; 251 | 252 | const appDataDirPath = 253 | await getDataDir(); 254 | 255 | await copyFile( 256 | appDataDirPath + 257 | "html_export/" + 258 | chatroom, 259 | filepath 260 | ); 261 | 262 | console.log({ 263 | src: 264 | appDataDirPath + 265 | "html_export/attachments/", 266 | dest: 267 | filepath?.split("/")[0] + 268 | "_attachments/", 269 | }); 270 | }); 271 | }} 272 | className="w-5 cursor-pointer" 273 | /> 274 |
275 | ))} 276 |
277 |
278 | ) : ( 279 | <> 280 | )} 281 | 282 | {!messagesGroups ? ( 283 | <> 284 | ) : ( 285 |
286 | {Object.values(messagesGroups) 287 | .sort((a, b) => { 288 | if (sortBy === "Date") { 289 | return ( 290 | new Date(b.messages[0].date).getTime() - 291 | new Date(a.messages[0].date).getTime() 292 | ); 293 | } else { 294 | return a.address 295 | .toString() 296 | .localeCompare(b.address.toString()); 297 | } 298 | }) 299 | .map((group) => ( 300 | 301 | ))} 302 |
303 | )} 304 |
305 | ); 306 | } 307 | 308 | function ThemedButton(props: { 309 | children: React.ReactNode; 310 | onClick: () => void; 311 | disabled?: boolean; 312 | }) { 313 | return ( 314 | 371 | 372 | 373 |
374 |
375 |
376 |
377 | Last Message 378 |
379 |
380 | {props.group.messages 381 | .filter((v) => v.text != null) 382 | .slice(0, 20) 383 | .reverse() 384 | .map((message) => ( 385 |
404 | {message.num_attachments > 0 && ( 405 |
406 | 407 |
408 | )} 409 | {message.text} 410 |
411 | ))} 412 |
413 |
414 | Last Message Date 415 |
416 |
417 | {moment(props.group.messages[0].date).format( 418 | // "Sunday, February 14th 2010, 3:25:50 pm" 419 | "MMMM Do YYYY, h:mma" 420 | )} 421 |
422 |
423 |
424 |
425 | 426 | ); 427 | } 428 | 429 | function DatesSelector(props: { 430 | startDate: Date; 431 | setStartDate: (date: Date) => void; 432 | endDate: Date; 433 | setEndDate: (date: Date) => void; 434 | }) { 435 | return ( 436 |
437 |
438 |

From Date

439 | 447 |
448 |
449 |

To Date

450 | 459 |
460 |
461 | ); 462 | } 463 | -------------------------------------------------------------------------------- /src-tauri/src/app/options.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{crate_version, Arg, ArgAction, ArgMatches, Command}; 4 | 5 | use imessage_database::{ 6 | tables::{attachment::DEFAULT_ATTACHMENT_ROOT, table::DEFAULT_PATH_IOS}, 7 | util::{ 8 | dirs::{default_db_path, home}, 9 | platform::Platform, 10 | query_context::QueryContext, 11 | }, 12 | }; 13 | 14 | use crate::app::{ 15 | attachment_manager::AttachmentManager, error::RuntimeError, export_type::ExportType, 16 | }; 17 | 18 | /// Default export directory name 19 | pub const DEFAULT_OUTPUT_DIR: &str = "imessage_export"; 20 | 21 | // CLI Arg Names 22 | pub const OPTION_DB_PATH: &str = "db-path"; 23 | pub const OPTION_ATTACHMENT_ROOT: &str = "attachment-root"; 24 | pub const OPTION_ATTACHMENT_MANAGER: &str = "copy-method"; 25 | pub const OPTION_DIAGNOSTIC: &str = "diagnostics"; 26 | pub const OPTION_EXPORT_TYPE: &str = "format"; 27 | pub const OPTION_EXPORT_PATH: &str = "export-path"; 28 | pub const OPTION_START_DATE: &str = "start-date"; 29 | pub const OPTION_END_DATE: &str = "end-date"; 30 | pub const OPTION_DISABLE_LAZY_LOADING: &str = "no-lazy"; 31 | pub const OPTION_CUSTOM_NAME: &str = "custom-name"; 32 | pub const OPTION_PLATFORM: &str = "platform"; 33 | pub const OPTION_BYPASS_FREE_SPACE_CHECK: &str = "ignore-disk-warning"; 34 | 35 | // Other CLI Text 36 | pub const SUPPORTED_FILE_TYPES: &str = "txt, html"; 37 | pub const SUPPORTED_PLATFORMS: &str = "macOS, iOS"; 38 | pub const SUPPORTED_ATTACHMENT_MANAGER_MODES: &str = "compatible, efficient, disabled"; 39 | pub const ABOUT: &str = concat!( 40 | "The `imessage-exporter` binary exports iMessage data to\n", 41 | "`txt` or `html` formats. It can also run diagnostics\n", 42 | "to find problems with the iMessage database." 43 | ); 44 | 45 | #[derive(Debug, PartialEq, Eq)] 46 | pub struct Options { 47 | /// Path to database file 48 | pub db_path: PathBuf, 49 | /// Custom path to attachments 50 | pub attachment_root: Option, 51 | /// The attachment manager type used to copy files 52 | pub attachment_manager: AttachmentManager, 53 | /// If true, emit diagnostic information to stdout 54 | pub diagnostic: bool, 55 | /// The type of file we are exporting data to 56 | pub export_type: Option, 57 | /// Where the app will save exported data 58 | pub export_path: PathBuf, 59 | /// Query context describing SQL query filters 60 | pub query_context: QueryContext, 61 | /// If true, do not include `loading="lazy"` in HTML exports 62 | pub no_lazy: bool, 63 | /// Custom name for database owner in output 64 | pub custom_name: Option, 65 | /// The database source's platform 66 | pub platform: Platform, 67 | /// If true, disable the free disk space check 68 | pub ignore_disk_space: bool, 69 | } 70 | 71 | impl Options { 72 | pub fn from_args(args: &ArgMatches) -> Result { 73 | let user_path: Option<&String> = args.get_one(OPTION_DB_PATH); 74 | let attachment_root: Option<&String> = args.get_one(OPTION_ATTACHMENT_ROOT); 75 | let attachment_manager_type: Option<&String> = args.get_one(OPTION_ATTACHMENT_MANAGER); 76 | let diagnostic = args.get_flag(OPTION_DIAGNOSTIC); 77 | let export_file_type: Option<&String> = args.get_one(OPTION_EXPORT_TYPE); 78 | let user_export_path: Option<&String> = args.get_one(OPTION_EXPORT_PATH); 79 | let start_date: Option<&String> = args.get_one(OPTION_START_DATE); 80 | let end_date: Option<&String> = args.get_one(OPTION_END_DATE); 81 | let no_lazy = args.get_flag(OPTION_DISABLE_LAZY_LOADING); 82 | let custom_name: Option<&String> = args.get_one(OPTION_CUSTOM_NAME); 83 | let platform_type: Option<&String> = args.get_one(OPTION_PLATFORM); 84 | let ignore_disk_space = args.get_flag(OPTION_BYPASS_FREE_SPACE_CHECK); 85 | 86 | // Build the export type 87 | let export_type: Option = match export_file_type { 88 | Some(export_type_str) => { 89 | Some(ExportType::from_cli(export_type_str).ok_or(RuntimeError::InvalidOptions(format!( 90 | "{export_type_str} is not a valid export type! Must be one of <{SUPPORTED_FILE_TYPES}>" 91 | )))?) 92 | } 93 | None => None, 94 | }; 95 | 96 | // Ensure an export type is specified if other export options are selected 97 | if attachment_manager_type.is_some() && export_file_type.is_none() { 98 | return Err(RuntimeError::InvalidOptions(format!( 99 | "Option {OPTION_ATTACHMENT_MANAGER} is enabled, which requires `--{OPTION_EXPORT_TYPE}`" 100 | ))); 101 | } 102 | if user_export_path.is_some() && export_file_type.is_none() { 103 | return Err(RuntimeError::InvalidOptions(format!( 104 | "Option {OPTION_EXPORT_PATH} is enabled, which requires `--{OPTION_EXPORT_TYPE}`" 105 | ))); 106 | } 107 | if start_date.is_some() && export_file_type.is_none() { 108 | return Err(RuntimeError::InvalidOptions(format!( 109 | "Option {OPTION_START_DATE} is enabled, which requires `--{OPTION_EXPORT_TYPE}`" 110 | ))); 111 | } 112 | if end_date.is_some() && export_file_type.is_none() { 113 | return Err(RuntimeError::InvalidOptions(format!( 114 | "Option {OPTION_END_DATE} is enabled, which requires `--{OPTION_EXPORT_TYPE}`" 115 | ))); 116 | } 117 | 118 | // Warn the user if they are exporting to a file type for which lazy loading has no effect 119 | if no_lazy && export_file_type != Some(&"html".to_string()) { 120 | eprintln!( 121 | "Option {OPTION_DISABLE_LAZY_LOADING} is enabled, but the format specified is not `html`!" 122 | ); 123 | } 124 | 125 | // Ensure that if diagnostics are enabled, no other options are 126 | if diagnostic && attachment_manager_type.is_some() { 127 | return Err(RuntimeError::InvalidOptions(format!( 128 | "Diagnostics are enabled; {OPTION_ATTACHMENT_MANAGER} is disallowed" 129 | ))); 130 | } 131 | if diagnostic && user_export_path.is_some() { 132 | return Err(RuntimeError::InvalidOptions(format!( 133 | "Diagnostics are enabled; {OPTION_EXPORT_PATH} is disallowed" 134 | ))); 135 | } 136 | if diagnostic && export_file_type.is_some() { 137 | return Err(RuntimeError::InvalidOptions(format!( 138 | "Diagnostics are enabled; {OPTION_EXPORT_TYPE} is disallowed" 139 | ))); 140 | } 141 | if diagnostic && start_date.is_some() { 142 | return Err(RuntimeError::InvalidOptions(format!( 143 | "Diagnostics are enabled; {OPTION_START_DATE} is disallowed" 144 | ))); 145 | } 146 | if diagnostic && end_date.is_some() { 147 | return Err(RuntimeError::InvalidOptions(format!( 148 | "Diagnostics are enabled; {OPTION_END_DATE} is disallowed" 149 | ))); 150 | } 151 | 152 | // Build query context 153 | let mut query_context = QueryContext::default(); 154 | if let Some(start) = start_date { 155 | if let Err(why) = query_context.set_start(start) { 156 | return Err(RuntimeError::InvalidOptions(format!("{why}"))); 157 | } 158 | } 159 | if let Some(end) = end_date { 160 | if let Err(why) = query_context.set_end(end) { 161 | return Err(RuntimeError::InvalidOptions(format!("{why}"))); 162 | } 163 | } 164 | 165 | // We have to allocate a PathBuf here because it can be created from data owned by this function in the default state 166 | let db_path = match user_path { 167 | Some(path) => PathBuf::from(path), 168 | None => default_db_path(), 169 | }; 170 | 171 | // Build the Platform 172 | let platform = match platform_type { 173 | Some(platform_str) => Platform::from_cli(platform_str).ok_or( 174 | RuntimeError::InvalidOptions(format!( 175 | "{platform_str} is not a valid platform! Must be one of <{SUPPORTED_PLATFORMS}>")), 176 | )?, 177 | None => Platform::determine(&db_path), 178 | }; 179 | 180 | // Validate that the custom attachment root exists, if provided 181 | if let Some(path) = attachment_root { 182 | let custom_attachment_path = PathBuf::from(path); 183 | if !custom_attachment_path.exists() { 184 | return Err(RuntimeError::InvalidOptions(format!( 185 | "Supplied {OPTION_ATTACHMENT_ROOT} `{path}` does not exist!" 186 | ))); 187 | } 188 | }; 189 | 190 | // Warn the user that custom attachment roots have no effect on iOS backups 191 | if attachment_root.is_some() && platform == Platform::iOS { 192 | eprintln!( 193 | "Option {OPTION_ATTACHMENT_ROOT} is enabled, but the platform is {}, so the root will have no effect!", Platform::iOS 194 | ); 195 | } 196 | 197 | // Determine the attachment manager mode 198 | let attachment_manager_mode = match attachment_manager_type { 199 | Some(manager) => { 200 | AttachmentManager::from_cli(manager).ok_or(RuntimeError::InvalidOptions(format!( 201 | "{manager} is not a valid attachment manager mode! Must be one of <{SUPPORTED_ATTACHMENT_MANAGER_MODES}>" 202 | )))? 203 | } 204 | None => AttachmentManager::default(), 205 | }; 206 | 207 | // Validate the provided export path 208 | let export_path = validate_path(user_export_path, &export_type.as_ref())?; 209 | 210 | Ok(Options { 211 | db_path, 212 | attachment_root: attachment_root.cloned(), 213 | attachment_manager: attachment_manager_mode, 214 | diagnostic, 215 | export_type, 216 | export_path, 217 | query_context, 218 | no_lazy, 219 | custom_name: custom_name.cloned(), 220 | platform, 221 | ignore_disk_space, 222 | }) 223 | } 224 | 225 | /// Generate a path to the database based on the currently selected platform 226 | pub fn get_db_path(&self) -> PathBuf { 227 | match self.platform { 228 | Platform::iOS => self.db_path.join(DEFAULT_PATH_IOS), 229 | Platform::macOS => self.db_path.clone(), 230 | } 231 | } 232 | } 233 | 234 | /// Ensure export path is empty or does not contain files of the existing export type 235 | /// 236 | /// We have to allocate a `PathBuf` here because it can be created from data owned by this function in the default state 237 | fn validate_path( 238 | export_path: Option<&String>, 239 | export_type: &Option<&ExportType>, 240 | ) -> Result { 241 | // Build a path from the user-provided data or the default location 242 | let resolved_path = 243 | PathBuf::from(export_path.unwrap_or(&format!("{}/{DEFAULT_OUTPUT_DIR}", home()))); 244 | 245 | // If there is an export type selected, ensure we do not overwrite files of the same type 246 | if let Some(export_type) = export_type { 247 | if resolved_path.exists() { 248 | // Get the word to use if there is a problem with the specified path 249 | let path_word = match export_path { 250 | Some(_) => "Specified", 251 | None => "Default", 252 | }; 253 | 254 | // Ensure the directory exists and does not contain files of the same export type 255 | match resolved_path.read_dir() { 256 | Ok(files) => { 257 | let export_type_extension = export_type.to_string(); 258 | for file in files.flatten() { 259 | if file 260 | .path() 261 | .extension() 262 | .is_some_and(|s| s.to_str().unwrap_or("") == export_type_extension) 263 | { 264 | return Err(RuntimeError::InvalidOptions(format!( 265 | "{path_word} export path {resolved_path:?} contains existing \"{export_type}\" export data!" 266 | ))); 267 | } 268 | } 269 | } 270 | Err(why) => { 271 | return Err(RuntimeError::InvalidOptions(format!( 272 | "{path_word} export path {resolved_path:?} is not a valid directory: {why}" 273 | ))); 274 | } 275 | } 276 | } 277 | }; 278 | 279 | Ok(resolved_path) 280 | } 281 | 282 | /// Build the command line argument parser 283 | pub fn get_command() -> Command { 284 | Command::new("iMessage Exporter") 285 | .version(crate_version!()) 286 | .about(ABOUT) 287 | .arg_required_else_help(true) 288 | .arg( 289 | Arg::new(OPTION_DIAGNOSTIC) 290 | .short('d') 291 | .long(OPTION_DIAGNOSTIC) 292 | .help("Print diagnostic information and exit\n") 293 | .action(ArgAction::SetTrue) 294 | .display_order(0), 295 | ) 296 | .arg( 297 | Arg::new(OPTION_EXPORT_TYPE) 298 | .short('f') 299 | .long(OPTION_EXPORT_TYPE) 300 | .help("Specify a single file format to export messages into\n") 301 | .display_order(1) 302 | .value_name(SUPPORTED_FILE_TYPES), 303 | ) 304 | .arg( 305 | Arg::new(OPTION_ATTACHMENT_MANAGER) 306 | .short('c') 307 | .long(OPTION_ATTACHMENT_MANAGER) 308 | .help(format!("Specify an optional method to use when copying message attachments\nCompatible will convert HEIC files to JPEG\nEfficient will copy files without converting anything\nIf omitted, the default is `{}`\n", AttachmentManager::default())) 309 | .display_order(2) 310 | .value_name(SUPPORTED_ATTACHMENT_MANAGER_MODES), 311 | ) 312 | .arg( 313 | Arg::new(OPTION_DB_PATH) 314 | .short('p') 315 | .long(OPTION_DB_PATH) 316 | .help(format!("Specify an optional custom path for the iMessage database location\nFor macOS, specify a path to a `chat.db` file\nFor iOS, specify a path to the root of an unencrypted backup directory\nIf omitted, the default directory is {}\n", default_db_path().display())) 317 | .display_order(3) 318 | .value_name("path/to/source"), 319 | ) 320 | .arg( 321 | Arg::new(OPTION_ATTACHMENT_ROOT) 322 | .short('r') 323 | .long(OPTION_ATTACHMENT_ROOT) 324 | .help(format!("Specify an optional custom path to look for attachments in (macOS only)\nOnly use this if attachments are stored separately from the database's default location\nThe default location is {}\n", DEFAULT_ATTACHMENT_ROOT.replacen('~', &home(), 1))) 325 | .display_order(4) 326 | .value_name("path/to/attachments"), 327 | ) 328 | .arg( 329 | Arg::new(OPTION_PLATFORM) 330 | .short('a') 331 | .long(OPTION_PLATFORM) 332 | .help("Specify the platform the database was created on\nIf omitted, the platform type is determined automatically\n") 333 | .display_order(5) 334 | .value_name(SUPPORTED_PLATFORMS), 335 | ) 336 | .arg( 337 | Arg::new(OPTION_EXPORT_PATH) 338 | .short('o') 339 | .long(OPTION_EXPORT_PATH) 340 | .help(format!("Specify an optional custom directory for outputting exported data\nIf omitted, the default directory is {}/{DEFAULT_OUTPUT_DIR}\n", home())) 341 | .display_order(6) 342 | .value_name("path/to/save/files"), 343 | ) 344 | .arg( 345 | Arg::new(OPTION_START_DATE) 346 | .short('s') 347 | .long(OPTION_START_DATE) 348 | .help("The start date filter\nOnly messages sent on or after this date will be included\n") 349 | .display_order(7) 350 | .value_name("YYYY-MM-DD"), 351 | ) 352 | .arg( 353 | Arg::new(OPTION_END_DATE) 354 | .short('e') 355 | .long(OPTION_END_DATE) 356 | .help("The end date filter\nOnly messages sent before this date will be included\n") 357 | .display_order(8) 358 | .value_name("YYYY-MM-DD"), 359 | ) 360 | .arg( 361 | Arg::new(OPTION_DISABLE_LAZY_LOADING) 362 | .short('l') 363 | .long(OPTION_DISABLE_LAZY_LOADING) 364 | .help("Do not include `loading=\"lazy\"` in HTML export `img` tags\nThis will make pages load slower but PDF generation work\n") 365 | .action(ArgAction::SetTrue) 366 | .display_order(9), 367 | ) 368 | .arg( 369 | Arg::new(OPTION_CUSTOM_NAME) 370 | .short('m') 371 | .long(OPTION_CUSTOM_NAME) 372 | .help("Specify an optional custom name for the database owner's messages in exports\n") 373 | .display_order(10) 374 | ) 375 | .arg( 376 | Arg::new(OPTION_BYPASS_FREE_SPACE_CHECK) 377 | .short('b') 378 | .long(OPTION_BYPASS_FREE_SPACE_CHECK) 379 | .help("Bypass the disk space check when exporting data\nBy default, exports will not run if there is not enough free disk space\n") 380 | .action(ArgAction::SetTrue) 381 | .display_order(11) 382 | ) 383 | } 384 | 385 | #[cfg(test)] 386 | mod arg_tests { 387 | use imessage_database::util::{ 388 | dirs::default_db_path, platform::Platform, query_context::QueryContext, 389 | }; 390 | 391 | use crate::app::{ 392 | attachment_manager::AttachmentManager, 393 | export_type::ExportType, 394 | options::{get_command, validate_path, Options}, 395 | }; 396 | 397 | #[test] 398 | fn can_build_option_diagnostic_flag() { 399 | // Get matches from sample args 400 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-d"]; 401 | let command = get_command(); 402 | let args = command.get_matches_from(cli_args); 403 | 404 | // Build the Options 405 | let actual = Options::from_args(&args).unwrap(); 406 | 407 | // Expected data 408 | let expected = Options { 409 | db_path: default_db_path(), 410 | attachment_root: None, 411 | attachment_manager: AttachmentManager::default(), 412 | diagnostic: true, 413 | export_type: None, 414 | export_path: validate_path(None, &None).unwrap(), 415 | query_context: QueryContext::default(), 416 | no_lazy: false, 417 | custom_name: None, 418 | platform: Platform::default(), 419 | ignore_disk_space: false, 420 | }; 421 | 422 | assert_eq!(actual, expected); 423 | } 424 | 425 | #[test] 426 | fn cant_build_option_diagnostic_flag_with_export_type() { 427 | // Get matches from sample args 428 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-d", "-f", "txt"]; 429 | let command = get_command(); 430 | let args = command.get_matches_from(cli_args); 431 | 432 | // Build the Options 433 | let actual = Options::from_args(&args); 434 | 435 | assert!(actual.is_err()); 436 | } 437 | 438 | #[test] 439 | fn cant_build_option_diagnostic_flag_with_export_path() { 440 | // Get matches from sample args 441 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-d", "-o", "~/test"]; 442 | let command = get_command(); 443 | let args = command.get_matches_from(cli_args); 444 | 445 | // Build the Options 446 | let actual = Options::from_args(&args); 447 | 448 | assert!(actual.is_err()); 449 | } 450 | 451 | #[test] 452 | fn cant_build_option_diagnostic_flag_with_attachment_manager() { 453 | // Get matches from sample args 454 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-d", "-c", "compatible"]; 455 | let command = get_command(); 456 | let args = command.get_matches_from(cli_args); 457 | 458 | // Build the Options 459 | let actual = Options::from_args(&args); 460 | 461 | assert!(actual.is_err()); 462 | } 463 | 464 | #[test] 465 | fn cant_build_option_diagnostic_flag_with_start_date() { 466 | // Get matches from sample args 467 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-d", "-s", "2020-01-01"]; 468 | let command = get_command(); 469 | let args = command.get_matches_from(cli_args); 470 | 471 | // Build the Options 472 | let actual = Options::from_args(&args); 473 | 474 | assert!(actual.is_err()); 475 | } 476 | 477 | #[test] 478 | fn cant_build_option_diagnostic_flag_with_end() { 479 | // Get matches from sample args 480 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-d", "-e", "2020-01-01"]; 481 | let command = get_command(); 482 | let args = command.get_matches_from(cli_args); 483 | 484 | // Build the Options 485 | let actual = Options::from_args(&args); 486 | 487 | assert!(actual.is_err()); 488 | } 489 | 490 | #[test] 491 | fn can_build_option_export_html() { 492 | // Get matches from sample args 493 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-f", "html", "-o", "/tmp"]; 494 | let command = get_command(); 495 | let args = command.get_matches_from(cli_args); 496 | 497 | // Build the Options 498 | let actual = Options::from_args(&args).unwrap(); 499 | 500 | // Expected data 501 | let tmp_dir = String::from("/tmp"); 502 | let expected = Options { 503 | db_path: default_db_path(), 504 | attachment_root: None, 505 | attachment_manager: AttachmentManager::default(), 506 | diagnostic: false, 507 | export_type: Some(ExportType::Html), 508 | export_path: validate_path(Some(&tmp_dir), &None).unwrap(), 509 | query_context: QueryContext::default(), 510 | no_lazy: false, 511 | custom_name: None, 512 | platform: Platform::default(), 513 | ignore_disk_space: false, 514 | }; 515 | 516 | assert_eq!(actual, expected); 517 | } 518 | 519 | #[test] 520 | fn can_build_option_export_txt_no_lazy() { 521 | // Get matches from sample args 522 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-f", "txt", "-l"]; 523 | let command = get_command(); 524 | let args = command.get_matches_from(cli_args); 525 | 526 | // Build the Options 527 | let actual = Options::from_args(&args).unwrap(); 528 | 529 | // Expected data 530 | let expected = Options { 531 | db_path: default_db_path(), 532 | attachment_root: None, 533 | attachment_manager: AttachmentManager::default(), 534 | diagnostic: false, 535 | export_type: Some(ExportType::Txt), 536 | export_path: validate_path(None, &None).unwrap(), 537 | query_context: QueryContext::default(), 538 | no_lazy: true, 539 | custom_name: None, 540 | platform: Platform::default(), 541 | ignore_disk_space: false, 542 | }; 543 | 544 | assert_eq!(actual, expected); 545 | } 546 | 547 | #[test] 548 | fn cant_build_option_attachment_manager_no_export_type() { 549 | // Get matches from sample args 550 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-c", "compatible"]; 551 | let command = get_command(); 552 | let args = command.get_matches_from(cli_args); 553 | 554 | // Build the Options 555 | let actual = Options::from_args(&args); 556 | 557 | assert!(actual.is_err()); 558 | } 559 | 560 | #[test] 561 | fn cant_build_option_export_path_no_export_type() { 562 | // Get matches from sample args 563 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-o", "~/test"]; 564 | let command = get_command(); 565 | let args = command.get_matches_from(cli_args); 566 | 567 | // Build the Options 568 | let actual = Options::from_args(&args); 569 | 570 | assert!(actual.is_err()); 571 | } 572 | 573 | #[test] 574 | fn cant_build_option_start_date_path_no_export_type() { 575 | // Get matches from sample args 576 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-s", "2020-01-01"]; 577 | let command = get_command(); 578 | let args = command.get_matches_from(cli_args); 579 | 580 | // Build the Options 581 | let actual = Options::from_args(&args); 582 | 583 | assert!(actual.is_err()); 584 | } 585 | 586 | #[test] 587 | fn cant_build_option_end_date_path_no_export_type() { 588 | // Get matches from sample args 589 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-e", "2020-01-01"]; 590 | let command = get_command(); 591 | let args = command.get_matches_from(cli_args); 592 | 593 | // Build the Options 594 | let actual = Options::from_args(&args); 595 | 596 | assert!(actual.is_err()); 597 | } 598 | 599 | #[test] 600 | fn cant_build_option_invalid_date() { 601 | // Get matches from sample args 602 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-f", "html", "-e", "2020-32-32"]; 603 | let command = get_command(); 604 | let args = command.get_matches_from(cli_args); 605 | 606 | // Build the Options 607 | let actual = Options::from_args(&args); 608 | 609 | assert!(actual.is_err()); 610 | } 611 | 612 | #[test] 613 | fn cant_build_option_invalid_platform() { 614 | // Get matches from sample args 615 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-a", "iPad"]; 616 | let command = get_command(); 617 | let args = command.get_matches_from(cli_args); 618 | 619 | // Build the Options 620 | let actual = Options::from_args(&args); 621 | 622 | assert!(actual.is_err()); 623 | } 624 | 625 | #[test] 626 | fn cant_build_option_invalid_export_type() { 627 | // Get matches from sample args 628 | let cli_args: Vec<&str> = vec!["imessage-exporter", "-f", "pdf"]; 629 | let command = get_command(); 630 | let args = command.get_matches_from(cli_args); 631 | 632 | // Build the Options 633 | let actual = Options::from_args(&args); 634 | 635 | assert!(actual.is_err()); 636 | } 637 | } 638 | 639 | #[cfg(test)] 640 | mod path_tests { 641 | use std::fs; 642 | use std::io::Write; 643 | use std::path::PathBuf; 644 | 645 | use crate::app::{ 646 | export_type::ExportType, 647 | options::{validate_path, DEFAULT_OUTPUT_DIR}, 648 | }; 649 | use imessage_database::util::dirs::home; 650 | 651 | #[test] 652 | fn can_validate_empty() { 653 | let tmp = String::from("/tmp"); 654 | let export_path = Some(&tmp); 655 | let export_type = Some(ExportType::Txt); 656 | 657 | let result = validate_path(export_path, &export_type.as_ref()); 658 | 659 | assert_eq!(result.unwrap(), PathBuf::from("/tmp")); 660 | } 661 | 662 | #[test] 663 | fn can_validate_different_type() { 664 | let tmp = String::from("/tmp"); 665 | let export_path = Some(&tmp); 666 | let export_type = Some(ExportType::Txt); 667 | 668 | let result = validate_path(export_path, &export_type.as_ref()); 669 | 670 | let mut tmp = PathBuf::from("/tmp"); 671 | tmp.push("fake1.html"); 672 | let mut file = fs::File::create(&tmp).unwrap(); 673 | file.write_all(&[]).unwrap(); 674 | 675 | assert_eq!(result.unwrap(), PathBuf::from("/tmp")); 676 | fs::remove_file(&tmp).unwrap(); 677 | } 678 | 679 | #[test] 680 | fn can_validate_same_type() { 681 | let tmp = String::from("/tmp"); 682 | let export_path = Some(&tmp); 683 | let export_type = Some(ExportType::Txt); 684 | 685 | let result = validate_path(export_path, &export_type.as_ref()); 686 | 687 | let mut tmp = PathBuf::from("/tmp"); 688 | tmp.push("fake2.txt"); 689 | let mut file = fs::File::create(&tmp).unwrap(); 690 | file.write_all(&[]).unwrap(); 691 | 692 | assert_eq!(result.unwrap(), PathBuf::from("/tmp")); 693 | fs::remove_file(&tmp).unwrap(); 694 | } 695 | 696 | #[test] 697 | fn can_validate_none() { 698 | let export_path = None; 699 | let export_type = None; 700 | 701 | let result = validate_path(export_path, &export_type); 702 | 703 | assert_eq!( 704 | result.unwrap(), 705 | PathBuf::from(&format!("{}/{DEFAULT_OUTPUT_DIR}", home())) 706 | ); 707 | } 708 | } 709 | -------------------------------------------------------------------------------- /src-tauri/src/app/runtime.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::min, 3 | collections::{BTreeSet, HashMap, HashSet}, 4 | fs::create_dir_all, 5 | path::PathBuf, 6 | }; 7 | 8 | use fs2::available_space; 9 | use rusqlite::Connection; 10 | 11 | use crate::{ 12 | app::{ 13 | attachment_manager::AttachmentManager, converter::Converter, error::RuntimeError, 14 | export_type::ExportType, options::Options, sanitizers::sanitize_filename, 15 | }, 16 | Exporter, HTML, TXT, 17 | }; 18 | 19 | use imessage_database::{ 20 | error::table::TableError, 21 | tables::{ 22 | attachment::Attachment, 23 | chat::Chat, 24 | chat_handle::ChatToHandle, 25 | handle::Handle, 26 | messages::Message, 27 | table::{ 28 | get_connection, get_db_size, Cacheable, Deduplicate, Diagnostic, ATTACHMENTS_DIR, 29 | MAX_LENGTH, ME, ORPHANED, UNKNOWN, 30 | }, 31 | }, 32 | util::{dates::get_offset, size::format_file_size}, 33 | }; 34 | 35 | /// Stores the application state and handles application lifecycle 36 | pub struct Config { 37 | /// Map of chatroom ID to chatroom information 38 | pub chatrooms: HashMap, 39 | // Map of chatroom ID to an internal unique chatroom ID 40 | pub real_chatrooms: HashMap, 41 | /// Map of chatroom ID to chatroom participants 42 | pub chatroom_participants: HashMap>, 43 | /// Map of participant ID to contact info 44 | pub participants: HashMap, 45 | /// Map of participant ID to an internal unique participant ID 46 | pub real_participants: HashMap, 47 | /// Messages that are reactions to other messages 48 | pub reactions: HashMap>>, 49 | /// App configuration options 50 | pub options: Options, 51 | /// Global date offset used by the iMessage database: 52 | pub offset: i64, 53 | /// The connection we use to query the database 54 | pub db: Connection, 55 | /// Converter type used when converting image files 56 | pub converter: Option, 57 | } 58 | 59 | impl Config { 60 | /// Get a deduplicated chat ID or a default value 61 | pub fn conversation(&self, message: &Message) -> Option<(&Chat, &i32)> { 62 | match message.chat_id.or(message.deleted_from) { 63 | Some(chat_id) => { 64 | if let Some(chatroom) = self.chatrooms.get(&chat_id) { 65 | self.real_chatrooms.get(&chat_id).map(|id| (chatroom, id)) 66 | } else { 67 | eprintln!("Chat ID {chat_id} does not exist in chat table!"); 68 | None 69 | } 70 | } 71 | // No chat_id provided 72 | None => None, 73 | } 74 | } 75 | 76 | /// Get the attachment path for the current session 77 | pub fn attachment_path(&self) -> PathBuf { 78 | let mut path = self.options.export_path.clone(); 79 | path.push(ATTACHMENTS_DIR); 80 | path 81 | } 82 | 83 | /// Get the attachment path for a specific chat ID 84 | pub fn conversation_attachment_path(&self, chat_id: Option) -> String { 85 | if let Some(chat_id) = chat_id { 86 | if let Some(real_id) = self.real_chatrooms.get(&chat_id) { 87 | return real_id.to_string(); 88 | } 89 | } 90 | String::from(ORPHANED) 91 | } 92 | 93 | /// Generate a file path for an attachment 94 | /// 95 | /// If the attachment was copied, use that path 96 | /// if not, default to the filename 97 | pub fn message_attachment_path(&self, attachment: &Attachment) -> String { 98 | // Build a relative filepath from the fully qualified one on the `Attachment` 99 | match &attachment.copied_path { 100 | Some(path) => { 101 | if let Ok(relative_path) = path.strip_prefix(&self.options.export_path) { 102 | return relative_path.display().to_string(); 103 | } 104 | path.display().to_string() 105 | } 106 | None => attachment 107 | .resolved_attachment_path( 108 | &self.options.platform, 109 | &self.options.db_path, 110 | self.options.attachment_root.as_deref(), 111 | ) 112 | .unwrap_or(attachment.filename().to_string()), 113 | } 114 | } 115 | 116 | /// Get a filename for a chat, possibly using cached data. 117 | /// 118 | /// If the chat has an assigned name, use that, truncating if necessary. 119 | /// 120 | /// If it does not, first try and make a flat list of its members. Failing that, use the unique `chat_identifier` field. 121 | pub fn filename(&self, chatroom: &Chat) -> String { 122 | let filename = match &chatroom.display_name() { 123 | // If there is a display name, use that 124 | Some(name) => { 125 | format!( 126 | "{} - {}", 127 | &name[..min(MAX_LENGTH, name.len())], 128 | chatroom.rowid 129 | ) 130 | } 131 | // Fallback if there is no name set 132 | None => { 133 | if let Some(participants) = self.chatroom_participants.get(&chatroom.rowid) { 134 | self.filename_from_participants(participants) 135 | } else { 136 | eprintln!( 137 | "Found error: message chat ID {} has no members!", 138 | chatroom.rowid 139 | ); 140 | chatroom.chat_identifier.clone() 141 | } 142 | } 143 | }; 144 | sanitize_filename(&filename) 145 | } 146 | 147 | /// Generate a filename from a set of participants, truncating if the name is too long 148 | /// 149 | /// - All names: 150 | /// - Contact 1, Contact 2 151 | /// - Truncated Names 152 | /// - Contact 1, Contact 2, ... Contact 13 and 4 others 153 | fn filename_from_participants(&self, participants: &BTreeSet) -> String { 154 | let mut added = 0; 155 | let mut out_s = String::with_capacity(MAX_LENGTH); 156 | for participant_id in participants { 157 | let participant = self.who(Some(*participant_id), false); 158 | if participant.len() + out_s.len() < MAX_LENGTH { 159 | if !out_s.is_empty() { 160 | out_s.push_str(", "); 161 | } 162 | out_s.push_str(participant); 163 | added += 1; 164 | } else { 165 | let extra = format!(", and {} others", participants.len() - added); 166 | let space_remaining = extra.len() + out_s.len(); 167 | if space_remaining >= MAX_LENGTH { 168 | out_s.replace_range((MAX_LENGTH - extra.len()).., &extra); 169 | } else if out_s.is_empty() { 170 | out_s.push_str(&participant[..MAX_LENGTH]); 171 | } else { 172 | out_s.push_str(&extra); 173 | } 174 | break; 175 | } 176 | } 177 | out_s 178 | } 179 | 180 | /// Create a new instance of the application 181 | /// 182 | /// # Example: 183 | /// 184 | /// ``` 185 | /// use crate::app::{ 186 | /// options::{from_command_line, Options}, 187 | /// runtime::Config, 188 | /// }; 189 | /// 190 | /// let args = from_command_line(); 191 | /// let options = Options::from_args(&args); 192 | /// let app = Config::new(options).unwrap(); 193 | /// ``` 194 | pub fn new(options: Options) -> Result { 195 | let conn = get_connection(&options.get_db_path()).map_err(RuntimeError::DatabaseError)?; 196 | eprintln!("Building cache..."); 197 | eprintln!("[1/4] Caching chats..."); 198 | let chatrooms = Chat::cache(&conn).map_err(RuntimeError::DatabaseError)?; 199 | eprintln!("[2/4] Caching chatrooms..."); 200 | let chatroom_participants = 201 | ChatToHandle::cache(&conn).map_err(RuntimeError::DatabaseError)?; 202 | eprintln!("[3/4] Caching participants..."); 203 | let participants = Handle::cache(&conn).map_err(RuntimeError::DatabaseError)?; 204 | eprintln!("[4/4] Caching reactions..."); 205 | let reactions = Message::cache(&conn).map_err(RuntimeError::DatabaseError)?; 206 | eprintln!("Cache built!"); 207 | Ok(Config { 208 | chatrooms, 209 | real_chatrooms: ChatToHandle::dedupe(&chatroom_participants), 210 | chatroom_participants, 211 | real_participants: Handle::dedupe(&participants), 212 | participants, 213 | reactions, 214 | options, 215 | offset: get_offset(), 216 | db: conn, 217 | converter: Converter::determine(), 218 | }) 219 | } 220 | 221 | /// Ensure there is available disk space for the requested export 222 | fn ensure_free_space(&self) -> Result<(), RuntimeError> { 223 | // Export size is usually about 6% the size of the db; we divide by 10 to over-estimate about 10% of the total size 224 | // for some safe headroom 225 | let total_db_size = 226 | get_db_size(&self.options.db_path).map_err(RuntimeError::DatabaseError)?; 227 | let mut estimated_export_size = total_db_size / 10; 228 | 229 | let free_space_at_location = 230 | available_space(&self.options.export_path).map_err(RuntimeError::DiskError)?; 231 | 232 | // Validate that there is enough disk space free to write the export 233 | if let AttachmentManager::Disabled = self.options.attachment_manager { 234 | if estimated_export_size >= free_space_at_location { 235 | return Err(RuntimeError::NotEnoughAvailableSpace( 236 | estimated_export_size, 237 | free_space_at_location, 238 | )); 239 | } 240 | } else { 241 | let total_attachment_size = Attachment::get_total_attachment_bytes(&self.db) 242 | .map_err(RuntimeError::DatabaseError)?; 243 | estimated_export_size += total_attachment_size; 244 | if (estimated_export_size + total_attachment_size) >= free_space_at_location { 245 | return Err(RuntimeError::NotEnoughAvailableSpace( 246 | estimated_export_size + total_attachment_size, 247 | free_space_at_location, 248 | )); 249 | } 250 | }; 251 | 252 | println!( 253 | "Estimated export size: {}", 254 | format_file_size(estimated_export_size) 255 | ); 256 | 257 | Ok(()) 258 | } 259 | 260 | /// Handles diagnostic tests for database 261 | fn run_diagnostic(&self) -> Result<(), TableError> { 262 | println!("\niMessage Database Diagnostics\n"); 263 | Handle::run_diagnostic(&self.db)?; 264 | Message::run_diagnostic(&self.db)?; 265 | Attachment::run_diagnostic(&self.db, &self.options.db_path, &self.options.platform)?; 266 | ChatToHandle::run_diagnostic(&self.db)?; 267 | 268 | // Global Diagnostics 269 | println!("Global diagnostic data:"); 270 | 271 | let total_db_size = get_db_size(&self.options.db_path)?; 272 | println!( 273 | " Total database size: {}", 274 | format_file_size(total_db_size) 275 | ); 276 | 277 | let unique_handles: HashSet = 278 | HashSet::from_iter(self.real_participants.values().cloned()); 279 | let duplicated_handles = self.participants.len() - unique_handles.len(); 280 | if duplicated_handles > 0 { 281 | println!(" Duplicated contacts: {duplicated_handles}"); 282 | } 283 | 284 | let unique_chats: HashSet = HashSet::from_iter(self.real_chatrooms.values().cloned()); 285 | let duplicated_chats = self.chatrooms.len() - unique_chats.len(); 286 | if duplicated_chats > 0 { 287 | println!(" Duplicated chats: {duplicated_chats}"); 288 | } 289 | 290 | Ok(()) 291 | } 292 | 293 | /// Start the app given the provided set of options. This will either run 294 | /// diagnostic tests on the database or export data to the specified file type. 295 | /// 296 | // # Example: 297 | /// 298 | /// ``` 299 | /// use crate::app::{ 300 | /// options::{from_command_line, Options}, 301 | /// runtime::Config, 302 | /// }; 303 | /// 304 | /// let args = from_command_line(); 305 | /// let options = Options::from_args(&args); 306 | /// let app = Config::new(options).unwrap(); 307 | /// app.start(); 308 | /// ``` 309 | pub fn start(&self) -> Result<(), RuntimeError> { 310 | if self.options.diagnostic { 311 | self.run_diagnostic().map_err(RuntimeError::DatabaseError)?; 312 | } else if let Some(export_type) = &self.options.export_type { 313 | // Ensure the path we want to export to exists 314 | create_dir_all(&self.options.export_path).map_err(RuntimeError::DiskError)?; 315 | 316 | // Ensure the path we want to copy attachments to exists, if requested 317 | if !matches!(self.options.attachment_manager, AttachmentManager::Disabled) { 318 | create_dir_all(self.attachment_path()).map_err(RuntimeError::DiskError)?; 319 | } 320 | 321 | // Ensure there is enough free disk space to write the export 322 | if !self.options.ignore_disk_space { 323 | self.ensure_free_space()?; 324 | } 325 | 326 | // Create exporter, pass it data we care about, then kick it off 327 | match export_type { 328 | ExportType::Html => { 329 | HTML::new(self).iter_messages()?; 330 | } 331 | ExportType::Txt => { 332 | TXT::new(self).iter_messages()?; 333 | } 334 | } 335 | } 336 | println!("Done!"); 337 | Ok(()) 338 | } 339 | 340 | /// Determine who sent a message 341 | pub fn who(&self, handle_id: Option, is_from_me: bool) -> &str { 342 | if is_from_me { 343 | return self.options.custom_name.as_deref().unwrap_or(ME); 344 | } else if let Some(handle_id) = handle_id { 345 | return match self.participants.get(&handle_id) { 346 | Some(contact) => contact, 347 | None => UNKNOWN, 348 | }; 349 | } 350 | UNKNOWN 351 | } 352 | } 353 | 354 | #[cfg(test)] 355 | mod filename_tests { 356 | use crate::{app::attachment_manager::AttachmentManager, Config, Options}; 357 | use imessage_database::{ 358 | tables::{ 359 | chat::Chat, 360 | table::{get_connection, MAX_LENGTH}, 361 | }, 362 | util::{dirs::default_db_path, platform::Platform, query_context::QueryContext}, 363 | }; 364 | use std::{ 365 | collections::{BTreeSet, HashMap}, 366 | path::PathBuf, 367 | }; 368 | 369 | fn fake_options() -> Options { 370 | Options { 371 | db_path: default_db_path(), 372 | attachment_root: None, 373 | attachment_manager: AttachmentManager::Disabled, 374 | diagnostic: false, 375 | export_type: None, 376 | export_path: PathBuf::new(), 377 | query_context: QueryContext::default(), 378 | no_lazy: false, 379 | custom_name: None, 380 | platform: Platform::macOS, 381 | ignore_disk_space: false, 382 | } 383 | } 384 | 385 | fn fake_chat() -> Chat { 386 | Chat { 387 | rowid: 0, 388 | chat_identifier: "Default".to_string(), 389 | service_name: Some(String::new()), 390 | display_name: None, 391 | } 392 | } 393 | 394 | fn fake_app(options: Options) -> Config { 395 | let connection = get_connection(&options.db_path).unwrap(); 396 | Config { 397 | chatrooms: HashMap::new(), 398 | real_chatrooms: HashMap::new(), 399 | chatroom_participants: HashMap::new(), 400 | participants: HashMap::new(), 401 | real_participants: HashMap::new(), 402 | reactions: HashMap::new(), 403 | options, 404 | offset: 0, 405 | db: connection, 406 | converter: Some(crate::app::converter::Converter::Sips), 407 | } 408 | } 409 | 410 | #[test] 411 | fn can_create() { 412 | let options = fake_options(); 413 | let app = fake_app(options); 414 | app.start().unwrap(); 415 | } 416 | 417 | #[test] 418 | fn can_get_filename_good() { 419 | let options = fake_options(); 420 | let mut app = fake_app(options); 421 | 422 | // Create participant data 423 | app.participants.insert(10, "Person 10".to_string()); 424 | app.participants.insert(11, "Person 11".to_string()); 425 | 426 | // Add participants 427 | let mut people = BTreeSet::new(); 428 | people.insert(10); 429 | people.insert(11); 430 | 431 | // Get filename 432 | let filename = app.filename_from_participants(&people); 433 | assert_eq!(filename, "Person 10, Person 11".to_string()); 434 | assert!(filename.len() <= MAX_LENGTH); 435 | } 436 | 437 | #[test] 438 | fn can_get_filename_long_multiple() { 439 | let options = fake_options(); 440 | let mut app = fake_app(options); 441 | 442 | // Create participant data 443 | app.participants.insert( 444 | 10, 445 | "Person With An Extremely and Excessively Long Name 10".to_string(), 446 | ); 447 | app.participants.insert( 448 | 11, 449 | "Person With An Extremely and Excessively Long Name 11".to_string(), 450 | ); 451 | app.participants.insert( 452 | 12, 453 | "Person With An Extremely and Excessively Long Name 12".to_string(), 454 | ); 455 | app.participants.insert( 456 | 13, 457 | "Person With An Extremely and Excessively Long Name 13".to_string(), 458 | ); 459 | app.participants.insert( 460 | 14, 461 | "Person With An Extremely and Excessively Long Name 14".to_string(), 462 | ); 463 | app.participants.insert( 464 | 15, 465 | "Person With An Extremely and Excessively Long Name 15".to_string(), 466 | ); 467 | app.participants.insert( 468 | 16, 469 | "Person With An Extremely and Excessively Long Name 16".to_string(), 470 | ); 471 | app.participants.insert( 472 | 17, 473 | "Person With An Extremely and Excessively Long Name 17".to_string(), 474 | ); 475 | 476 | // Add participants 477 | let mut people = BTreeSet::new(); 478 | people.insert(10); 479 | people.insert(11); 480 | people.insert(12); 481 | people.insert(13); 482 | people.insert(14); 483 | people.insert(15); 484 | people.insert(16); 485 | people.insert(17); 486 | 487 | // Get filename 488 | let filename = app.filename_from_participants(&people); 489 | assert_eq!(filename, "Person With An Extremely and Excessively Long Name 10, Person With An Extremely and Excessively Long Name 11, Person With An Extremely and Excessively Long Name 12, Person With An Extremely and Excessively Long Name 13, and 4 others".to_string()); 490 | assert!(filename.len() <= MAX_LENGTH); 491 | } 492 | 493 | #[test] 494 | fn can_get_filename_single_long() { 495 | let options = fake_options(); 496 | let mut app = fake_app(options); 497 | 498 | // Create participant data 499 | app.participants.insert(10, "He slipped his key into the lock, and we all very quietly entered the cell. The sleeper half turned, and then settled down once more into a deep slumber. Holmes stooped to the water-jug, moistened his sponge, and then rubbed it twice vigorously across and down the prisoner's face.".to_string()); 500 | 501 | // Add 1 person 502 | let mut people = BTreeSet::new(); 503 | people.insert(10); 504 | 505 | // Get filename 506 | let filename = app.filename_from_participants(&people); 507 | assert_eq!(filename, "He slipped his key into the lock, and we all very quietly entered the cell. The sleeper half turned, and then settled down once more into a deep slumber. Holmes stooped to the water-jug, moistened his sponge, and then rubbed it twice vigoro".to_string()); 508 | assert!(filename.len() <= MAX_LENGTH); 509 | } 510 | 511 | #[test] 512 | fn can_get_filename_chat_display_name_long() { 513 | let options = fake_options(); 514 | let app = fake_app(options); 515 | 516 | // Create chat 517 | let mut chat = fake_chat(); 518 | chat.display_name = Some("Life is infinitely stranger than anything which the mind of man could invent. We would not dare to conceive the things which are really mere commonplaces of existence. If we could fly out of that window hand in hand, hover over this great city, gently remove the roofs".to_string()); 519 | 520 | // Get filename 521 | let filename = app.filename(&chat); 522 | assert_eq!(filename, "Life is infinitely stranger than anything which the mind of man could invent. We would not dare to conceive the things which are really mere commonplaces of existence. If we could fly out of that window hand in hand, hover over this great c - 0"); 523 | } 524 | 525 | #[test] 526 | fn can_get_filename_chat_display_name_normal() { 527 | let options = fake_options(); 528 | let app = fake_app(options); 529 | 530 | // Create chat 531 | let mut chat = fake_chat(); 532 | chat.display_name = Some("Test Chat Name".to_string()); 533 | 534 | // Get filename 535 | let filename = app.filename(&chat); 536 | assert_eq!(filename, "Test Chat Name - 0"); 537 | } 538 | 539 | #[test] 540 | fn can_get_filename_chat_display_name_short() { 541 | let options = fake_options(); 542 | let app = fake_app(options); 543 | 544 | // Create chat 545 | let mut chat = fake_chat(); 546 | chat.display_name = Some("🤠".to_string()); 547 | 548 | // Get filename 549 | let filename = app.filename(&chat); 550 | assert_eq!(filename, "🤠 - 0"); 551 | } 552 | 553 | #[test] 554 | fn can_get_filename_chat_participants() { 555 | let options = fake_options(); 556 | let mut app = fake_app(options); 557 | 558 | // Create chat 559 | let chat = fake_chat(); 560 | 561 | // Create participant data 562 | app.participants.insert(10, "Person 10".to_string()); 563 | app.participants.insert(11, "Person 11".to_string()); 564 | 565 | // Add participants 566 | let mut people = BTreeSet::new(); 567 | people.insert(10); 568 | people.insert(11); 569 | app.chatroom_participants.insert(chat.rowid, people); 570 | 571 | // Get filename 572 | let filename = app.filename(&chat); 573 | assert_eq!(filename, "Person 10, Person 11"); 574 | } 575 | 576 | #[test] 577 | fn can_get_filename_chat_no_participants() { 578 | let options = fake_options(); 579 | let app = fake_app(options); 580 | 581 | // Create chat 582 | let chat = fake_chat(); 583 | 584 | // Get filename 585 | let filename = app.filename(&chat); 586 | assert_eq!(filename, "Default"); 587 | } 588 | } 589 | 590 | #[cfg(test)] 591 | mod who_tests { 592 | use crate::{app::attachment_manager::AttachmentManager, Config, Options}; 593 | use imessage_database::{ 594 | tables::{chat::Chat, messages::Message, table::get_connection}, 595 | util::{dirs::default_db_path, platform::Platform, query_context::QueryContext}, 596 | }; 597 | use std::{collections::HashMap, path::PathBuf}; 598 | 599 | fn fake_options() -> Options { 600 | Options { 601 | db_path: default_db_path(), 602 | attachment_root: None, 603 | attachment_manager: AttachmentManager::Disabled, 604 | diagnostic: false, 605 | export_type: None, 606 | export_path: PathBuf::new(), 607 | query_context: QueryContext::default(), 608 | no_lazy: false, 609 | custom_name: None, 610 | platform: Platform::macOS, 611 | ignore_disk_space: false, 612 | } 613 | } 614 | 615 | fn fake_chat() -> Chat { 616 | Chat { 617 | rowid: 0, 618 | chat_identifier: "Default".to_string(), 619 | service_name: Some(String::new()), 620 | display_name: None, 621 | } 622 | } 623 | 624 | fn fake_app(options: Options) -> Config { 625 | let connection = get_connection(&options.db_path).unwrap(); 626 | Config { 627 | chatrooms: HashMap::new(), 628 | real_chatrooms: HashMap::new(), 629 | chatroom_participants: HashMap::new(), 630 | participants: HashMap::new(), 631 | real_participants: HashMap::new(), 632 | reactions: HashMap::new(), 633 | options, 634 | offset: 0, 635 | db: connection, 636 | converter: Some(crate::app::converter::Converter::Sips), 637 | } 638 | } 639 | 640 | fn blank() -> Message { 641 | Message { 642 | rowid: i32::default(), 643 | guid: String::default(), 644 | text: None, 645 | service: Some("iMessage".to_string()), 646 | handle_id: Some(i32::default()), 647 | subject: None, 648 | date: i64::default(), 649 | date_read: i64::default(), 650 | date_delivered: i64::default(), 651 | is_from_me: false, 652 | is_read: false, 653 | item_type: 0, 654 | group_title: None, 655 | group_action_type: 0, 656 | associated_message_guid: None, 657 | associated_message_type: Some(i32::default()), 658 | balloon_bundle_id: None, 659 | expressive_send_style_id: None, 660 | thread_originator_guid: None, 661 | thread_originator_part: None, 662 | date_edited: 0, 663 | chat_id: None, 664 | num_attachments: 0, 665 | deleted_from: None, 666 | num_replies: 0, 667 | } 668 | } 669 | 670 | #[test] 671 | fn can_get_who_them() { 672 | let options = fake_options(); 673 | let mut app = fake_app(options); 674 | 675 | // Create participant data 676 | app.participants.insert(10, "Person 10".to_string()); 677 | 678 | // Get participant name 679 | let who = app.who(Some(10), false); 680 | assert_eq!(who, "Person 10".to_string()); 681 | } 682 | 683 | #[test] 684 | fn can_get_who_them_missing() { 685 | let options = fake_options(); 686 | let app = fake_app(options); 687 | 688 | // Get participant name 689 | let who = app.who(Some(10), false); 690 | assert_eq!(who, "Unknown".to_string()); 691 | } 692 | 693 | #[test] 694 | fn can_get_who_me() { 695 | let options = fake_options(); 696 | let app = fake_app(options); 697 | 698 | // Get participant name 699 | let who = app.who(Some(0), true); 700 | assert_eq!(who, "Me".to_string()); 701 | } 702 | 703 | #[test] 704 | fn can_get_who_me_custom() { 705 | let mut options = fake_options(); 706 | options.custom_name = Some("Name".to_string()); 707 | let app = fake_app(options); 708 | 709 | // Get participant name 710 | let who = app.who(Some(0), true); 711 | assert_eq!(who, "Name".to_string()); 712 | } 713 | 714 | #[test] 715 | fn can_get_who_none_me() { 716 | let options = fake_options(); 717 | let app = fake_app(options); 718 | 719 | // Get participant name 720 | let who = app.who(None, true); 721 | assert_eq!(who, "Me".to_string()); 722 | } 723 | 724 | #[test] 725 | fn can_get_who_none_them() { 726 | let options = fake_options(); 727 | let app = fake_app(options); 728 | 729 | // Get participant name 730 | let who = app.who(None, false); 731 | assert_eq!(who, "Unknown".to_string()); 732 | } 733 | 734 | #[test] 735 | fn can_get_chat_valid() { 736 | let options = fake_options(); 737 | let mut app = fake_app(options); 738 | 739 | // Create chat 740 | let chat = fake_chat(); 741 | app.chatrooms.insert(chat.rowid, chat); 742 | app.real_chatrooms.insert(0, 0); 743 | 744 | // Create message 745 | let mut message = blank(); 746 | message.chat_id = Some(0); 747 | 748 | // Get filename 749 | let (_, id) = app.conversation(&message).unwrap(); 750 | assert_eq!(id, &0); 751 | } 752 | 753 | #[test] 754 | fn can_get_chat_valid_deleted() { 755 | let options = fake_options(); 756 | let mut app = fake_app(options); 757 | 758 | // Create chat 759 | let chat = fake_chat(); 760 | app.chatrooms.insert(chat.rowid, chat); 761 | app.real_chatrooms.insert(0, 0); 762 | 763 | // Create message 764 | let mut message = blank(); 765 | message.chat_id = None; 766 | message.deleted_from = Some(0); 767 | 768 | // Get filename 769 | let (_, id) = app.conversation(&message).unwrap(); 770 | assert_eq!(id, &0); 771 | } 772 | 773 | #[test] 774 | fn can_get_chat_invalid() { 775 | let options = fake_options(); 776 | let mut app = fake_app(options); 777 | 778 | // Create chat 779 | let chat = fake_chat(); 780 | app.chatrooms.insert(chat.rowid, chat); 781 | app.real_chatrooms.insert(0, 0); 782 | 783 | // Create message 784 | let mut message = blank(); 785 | message.chat_id = Some(1); 786 | 787 | // Get filename 788 | let room = app.conversation(&message); 789 | assert!(room.is_none()); 790 | } 791 | 792 | #[test] 793 | fn can_get_chat_none() { 794 | let options = fake_options(); 795 | let mut app = fake_app(options); 796 | 797 | // Create chat 798 | let chat = fake_chat(); 799 | app.chatrooms.insert(chat.rowid, chat); 800 | app.real_chatrooms.insert(0, 0); 801 | 802 | // Create message 803 | let mut message = blank(); 804 | message.chat_id = None; 805 | message.deleted_from = None; 806 | 807 | // Get filename 808 | let room = app.conversation(&message); 809 | assert!(room.is_none()); 810 | } 811 | } 812 | 813 | #[cfg(test)] 814 | mod directory_tests { 815 | use crate::{app::attachment_manager::AttachmentManager, Config, Options}; 816 | use imessage_database::{ 817 | tables::{attachment::Attachment, table::get_connection}, 818 | util::{dirs::default_db_path, platform::Platform, query_context::QueryContext}, 819 | }; 820 | use std::{collections::HashMap, path::PathBuf}; 821 | 822 | fn fake_options() -> Options { 823 | Options { 824 | db_path: default_db_path(), 825 | attachment_root: None, 826 | attachment_manager: AttachmentManager::Disabled, 827 | diagnostic: false, 828 | export_type: None, 829 | export_path: PathBuf::new(), 830 | query_context: QueryContext::default(), 831 | no_lazy: false, 832 | custom_name: None, 833 | platform: Platform::macOS, 834 | ignore_disk_space: false, 835 | } 836 | } 837 | 838 | fn fake_app(options: Options) -> Config { 839 | let connection = get_connection(&options.db_path).unwrap(); 840 | Config { 841 | chatrooms: HashMap::new(), 842 | real_chatrooms: HashMap::new(), 843 | chatroom_participants: HashMap::new(), 844 | participants: HashMap::new(), 845 | real_participants: HashMap::new(), 846 | reactions: HashMap::new(), 847 | options, 848 | offset: 0, 849 | db: connection, 850 | converter: Some(crate::app::converter::Converter::Sips), 851 | } 852 | } 853 | 854 | pub fn fake_attachment() -> Attachment { 855 | Attachment { 856 | rowid: 0, 857 | filename: Some("a/b/c/d.jpg".to_string()), 858 | uti: Some("public.png".to_string()), 859 | mime_type: Some("image/png".to_string()), 860 | transfer_name: Some("d.jpg".to_string()), 861 | total_bytes: 100, 862 | is_sticker: false, 863 | hide_attachment: 0, 864 | copied_path: None, 865 | } 866 | } 867 | 868 | #[test] 869 | fn can_get_valid_attachment_sub_dir() { 870 | let options = fake_options(); 871 | let mut app = fake_app(options); 872 | 873 | // Create chatroom ID 874 | app.real_chatrooms.insert(0, 0); 875 | 876 | // Get subdirectory 877 | let sub_dir = app.conversation_attachment_path(Some(0)); 878 | assert_eq!(String::from("0"), sub_dir); 879 | } 880 | 881 | #[test] 882 | fn can_get_invalid_attachment_sub_dir() { 883 | let options = fake_options(); 884 | let mut app = fake_app(options); 885 | 886 | // Create chatroom ID 887 | app.real_chatrooms.insert(0, 0); 888 | 889 | // Get subdirectory 890 | let sub_dir = app.conversation_attachment_path(Some(1)); 891 | assert_eq!(String::from("orphaned"), sub_dir); 892 | } 893 | 894 | #[test] 895 | fn can_get_missing_attachment_sub_dir() { 896 | let options = fake_options(); 897 | let mut app = fake_app(options); 898 | 899 | // Create chatroom ID 900 | app.real_chatrooms.insert(0, 0); 901 | 902 | // Get subdirectory 903 | let sub_dir = app.conversation_attachment_path(None); 904 | assert_eq!(String::from("orphaned"), sub_dir); 905 | } 906 | 907 | #[test] 908 | fn can_get_path_not_copied() { 909 | let options = fake_options(); 910 | let app = fake_app(options); 911 | 912 | // Create attachment 913 | let attachment = fake_attachment(); 914 | 915 | let result = app.message_attachment_path(&attachment); 916 | let expected = String::from("a/b/c/d.jpg"); 917 | assert_eq!(result, expected); 918 | } 919 | 920 | #[test] 921 | fn can_get_path_copied() { 922 | let mut options = fake_options(); 923 | // Set an export path 924 | options.export_path = PathBuf::from("/Users/ReagentX/exports"); 925 | 926 | let app = fake_app(options); 927 | 928 | // Create attachment 929 | let mut attachment = fake_attachment(); 930 | let mut full_path = PathBuf::from("/Users/ReagentX/exports/attachments"); 931 | full_path.push(attachment.filename()); 932 | attachment.copied_path = Some(full_path); 933 | 934 | let result = app.message_attachment_path(&attachment); 935 | let expected = String::from("attachments/d.jpg"); 936 | assert_eq!(result, expected); 937 | } 938 | 939 | #[test] 940 | fn can_get_path_copied_bad() { 941 | let mut options = fake_options(); 942 | // Set an export path 943 | options.export_path = PathBuf::from("/Users/ReagentX/exports"); 944 | 945 | let app = fake_app(options); 946 | 947 | // Create attachment 948 | let mut attachment = fake_attachment(); 949 | attachment.copied_path = Some(PathBuf::from(attachment.filename.as_ref().unwrap())); 950 | 951 | let result = app.message_attachment_path(&attachment); 952 | let expected = String::from("a/b/c/d.jpg"); 953 | assert_eq!(result, expected); 954 | } 955 | } 956 | --------------------------------------------------------------------------------