├── 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 | 
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 | 
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 & 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 |
24 |
Jul 17, 2022 5:04:05 PM
25 | Me
26 |
27 |
28 |
41 |
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 | That's amazin |
158 |
159 |
160 |
161 |
162 | | Edited 9 seconds later |
163 | That's amazing! |
164 |
165 |
166 |
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 |
322 | );
323 | }
324 |
325 | function GroupItem(props: { group: MessageGroupType }) {
326 | // get 5 latest messages
327 | const { group } = props;
328 |
329 | return (
330 |
334 |
335 |
336 |
337 | {props.group.address}
338 |
339 |
340 |
341 |
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 |
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 |
--------------------------------------------------------------------------------