├── LICENSE ├── README.md └── FileSplitter.tsx /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 sioaeko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vencord File Splitter Plugin 2 | 3 | A Vencord plugin that bypasses Discord's 8MB file size limit by automatically splitting large files during upload and merging them back together upon download. 4 | 5 | ## Features 6 | 7 | - Automatically splits files larger than 8MB for upload 8 | - Seamlessly merges split files on download 9 | - Real-time progress tracking 10 | - Preserves original filename and metadata 11 | - Works with any file type 12 | - User-friendly interface 13 | 14 | ## Installation 15 | 16 | 1. Ensure you have Vencord installed 17 | 2. Copy `splitFilePlugin.tsx` to your Vencord plugins directory: 18 | - Windows: `%appdata%/Vencord/plugins` 19 | - Linux: `~/.config/Vencord/plugins` 20 | - macOS: `~/Library/Application Support/Vencord/plugins` 21 | 3. Restart Discord 22 | 4. Enable the "FileSplitter" plugin in Vencord settings 23 | 24 | ## Usage 25 | 26 | 1. Click the "Select Large File" button in Discord's chat 27 | 2. Choose your file 28 | 3. For files larger than 8MB: 29 | - The plugin automatically splits and uploads the file 30 | - Progress is shown for each part 31 | 4. For recipients: 32 | - Parts are automatically detected 33 | - Once all parts are received, the file is automatically merged and downloaded 34 | 35 | ## Technical Details 36 | 37 | - Chunk Size: 7.9MB (safely under Discord's 8MB limit) 38 | - File Format Support: All file types 39 | - Metadata Format: JSON (includes filename, chunk number, total chunks) 40 | - Storage Method: Base64 encoding 41 | - Automatic chunk ordering and verification 42 | 43 | ## Important Notes 44 | 45 | - Large files may take longer to split and merge 46 | - Monitor memory usage when handling very large files 47 | - All chunks must be received in the same channel for automatic merging 48 | - Internet connection stability is important for successful uploads 49 | 50 | ## Troubleshooting 51 | 52 | **Q: File isn't merging properly** 53 | A: Ensure all chunks were received and are in the same channel 54 | 55 | **Q: Upload failed mid-way** 56 | A: Check your internet connection and try again 57 | 58 | **Q: Plugin isn't showing up** 59 | A: Verify the plugin is installed in the correct directory and enabled in Vencord settings 60 | 61 | ## Contributing 62 | 63 | Contributions are welcome! Feel free to: 64 | - Report bugs 65 | - Suggest features 66 | - Submit pull requests 67 | - Improve documentation 68 | 69 | ## Security 70 | 71 | - All file processing is done locally 72 | - No external servers are used 73 | - Files are split and merged on your device 74 | 75 | ## License 76 | 77 | MIT License 78 | 79 | ## Credits 80 | 81 | - Original project: [ImTheSquid/SplitLargeFiles](https://github.com/ImTheSquid/SplitLargeFiles) 82 | - Ported to Vencord with additional improvements 83 | 84 | ## Requirements 85 | 86 | - Vencord 87 | - Discord Desktop Client 88 | - Sufficient storage space for temporary file chunks 89 | -------------------------------------------------------------------------------- /FileSplitter.tsx: -------------------------------------------------------------------------------- 1 | import { definePlugin, webpack, Patcher } from "@utils/webpack"; 2 | import { Button, Text, Forms } from "@webpack/common"; 3 | import { useCallback, useState, useEffect } from "@webpack/common"; 4 | import { NsUI } from "@utils/types"; // Vencord standard type import 5 | 6 | // Optimized chunk size. Set to 24.5MB, just under Discord's 25MB default limit for non-Nitro users. 7 | // The legacy 8MB limit is obsolete. 8 | const CHUNK_SIZE = 24.5 * 1024 * 1024; 9 | const CHUNK_TIMEOUT = 5 * 60 * 1000; // 5-minute cache expiration for incomplete files. 10 | 11 | /** 12 | * Metadata structure for a file chunk. 13 | * This object is JSON-stringified and sent as the message content, 14 | * excluding the binary payload which is sent as an attachment. 15 | */ 16 | interface FileChunkMetadata { 17 | type: "FileSplitterChunk"; // A unique identifier to distinguish chunk messages. 18 | index: number; 19 | total: number; 20 | originalName: string; 21 | originalSize: number; 22 | timestamp: number; 23 | } 24 | 25 | /** 26 | * Represents a chunk stored in the local ChunkManager. 27 | * Correlates the metadata with the attachment's resolvable CDN URL. 28 | */ 29 | interface StoredFileChunk extends FileChunkMetadata { 30 | url: string; // The Discord CDN URL for the attached file part. 31 | } 32 | 33 | // Interface for the local chunk storage. 34 | interface ChunkStorage { 35 | [key: string]: { // Keyed by originalName 36 | chunks: StoredFileChunk[]; 37 | lastUpdated: number; 38 | }; 39 | } 40 | 41 | // --- Webpack Module Resolution --- 42 | // Locating necessary Discord internal modules. 43 | 44 | const FileUploadStore = webpack.getModule(m => m?.upload && m?.instantBatchUpload); 45 | const MessageActions = webpack.getModule(m => m?.sendMessage); 46 | const Dispatcher = webpack.getModule(m => m?.dispatch && m?.subscribe); 47 | const ChannelStore = webpack.getModule(m => m?.getChannelId); 48 | // Module required for injecting the custom UI component. 49 | const ChannelTextArea = webpack.getModule(m => m.type?.displayName === "ChannelTextArea"); 50 | 51 | /** 52 | * Manages the assembly of file chunks received from messages. 53 | * This is a static class acting as a singleton storage manager. 54 | */ 55 | class ChunkManager { 56 | private static storage: ChunkStorage = {}; 57 | 58 | /** 59 | * Adds a received chunk to the storage. 60 | * @param chunk The stored chunk object containing metadata and URL. 61 | */ 62 | static addChunk(chunk: StoredFileChunk): void { 63 | const key = chunk.originalName; 64 | if (!this.storage[key]) { 65 | this.storage[key] = { 66 | chunks: [], 67 | lastUpdated: Date.now() 68 | }; 69 | } 70 | 71 | // Idempotency check: prevent processing or storing the same chunk index multiple times. 72 | if (!this.storage[key].chunks.some(c => c.index === chunk.index)) { 73 | this.storage[key].chunks.push(chunk); 74 | this.storage[key].lastUpdated = Date.now(); 75 | } 76 | } 77 | 78 | /** 79 | * Retrieves all stored chunks for a given file name. 80 | * @param fileName The original name of the file. 81 | * @returns An array of stored chunks or null if none found. 82 | */ 83 | static getChunks(fileName: string): StoredFileChunk[] | null { 84 | return this.storage[fileName]?.chunks || null; 85 | } 86 | 87 | /** 88 | * Garbage collection: Removes chunk data that hasn't been updated 89 | * within the CHUNK_TIMEOUT window. 90 | */ 91 | static cleanOldChunks(): void { 92 | const now = Date.now(); 93 | Object.keys(this.storage).forEach(key => { 94 | if (now - this.storage[key].lastUpdated > CHUNK_TIMEOUT) { 95 | delete this.storage[key]; 96 | console.log(`[FileSplitter] Garbage collected stale chunks for: ${key}`); 97 | } 98 | }); 99 | } 100 | } 101 | 102 | // --- Core Utilities --- 103 | 104 | /** 105 | * Type guard to validate if a parsed message object is a valid FileChunk. 106 | * @param chunk The object to validate (parsed from JSON). 107 | * @returns True if the object adheres to the FileChunkMetadata protocol. 108 | */ 109 | const isValidChunk = (chunk: any): chunk is FileChunkMetadata => { 110 | return ( 111 | typeof chunk === 'object' && 112 | chunk.type === "FileSplitterChunk" && // Verify the unique identifier. 113 | typeof chunk.index === 'number' && 114 | typeof chunk.total === 'number' && 115 | typeof chunk.originalName === 'string' && 116 | typeof chunk.originalSize === 'number' && 117 | typeof chunk.timestamp === 'number' 118 | ); 119 | }; 120 | 121 | /** 122 | * Asynchronously merges all file chunks into a single file and triggers a download. 123 | * @param chunks An array of StoredFileChunk objects. 124 | */ 125 | const handleFileMerge = async (chunks: StoredFileChunk[]) => { 126 | try { 127 | // Ensure chunks are in the correct order. 128 | chunks.sort((a, b) => a.index - b.index); 129 | 130 | const blobParts: Blob[] = []; 131 | for (const chunk of chunks) { 132 | // Asynchronously fetches the binary content from the chunk's CDN URL. 133 | const response = await fetch(chunk.url); 134 | if (!response.ok) { 135 | throw new Error(`Failed to fetch chunk ${chunk.index + 1} from ${chunk.url}`); 136 | } 137 | const blob = await response.blob(); 138 | blobParts.push(blob); 139 | } 140 | 141 | // Assemble the final file. 142 | const finalBlob = new Blob(blobParts); 143 | const finalFile = new File([finalBlob], chunks[0].originalName); 144 | 145 | // Generates a client-side download by creating a virtual link. 146 | const url = URL.createObjectURL(finalFile); 147 | const a = document.createElement('a'); 148 | a.href = url; 149 | a.download = finalFile.name; 150 | document.body.appendChild(a); 151 | a.click(); 152 | document.body.removeChild(a); 153 | URL.revokeObjectURL(url); 154 | 155 | console.log(`[FileSplitter] File merged and downloaded successfully: ${finalFile.name}`); 156 | 157 | } catch (error) { 158 | console.error('[FileSplitter] Error during file merge process:', error); 159 | } 160 | }; 161 | 162 | // --- React Component: UI --- 163 | 164 | /** 165 | * The React component that provides the UI for selecting and uploading large files. 166 | */ 167 | const SplitFileComponent = () => { 168 | const [status, setStatus] = useState(""); 169 | const [isUploading, setIsUploading] = useState(false); 170 | const [progress, setProgress] = useState(0); 171 | 172 | /** 173 | * Handles the core logic of splitting a file and uploading it in chunks. 174 | * @param file The file selected by the user. 175 | */ 176 | const handleFileSplit = useCallback(async (file: File) => { 177 | try { 178 | setIsUploading(true); 179 | const totalChunks = Math.ceil(file.size / CHUNK_SIZE); 180 | 181 | for (let i = 0; i < totalChunks; i++) { 182 | const start = i * CHUNK_SIZE; 183 | const end = Math.min(start + CHUNK_SIZE, file.size); 184 | 185 | // Step 1: Efficiently slice the file. Bypasses Base64 conversion, using the native Blob. 186 | const chunkBlob = file.slice(start, end); 187 | 188 | // Step 2: Construct the metadata payload. This JSON will be the message content. 189 | const metadata: FileChunkMetadata = { 190 | type: "FileSplitterChunk", 191 | index: i, 192 | total: totalChunks, 193 | originalName: file.name, 194 | originalSize: file.size, 195 | timestamp: Date.now() 196 | }; 197 | 198 | // Step 3: Re-wrap the Blob as a File object for the upload API. 199 | const chunkFile = new File( 200 | [chunkBlob], 201 | `${file.name}.part${String(i + 1).padStart(3, '0')}`, // Padded for lexical sorting. 202 | { type: 'application/octet-stream' } 203 | ); 204 | 205 | // Step 4: Dispatch the upload action, pairing the file part with its metadata message. 206 | await FileUploadStore.upload({ 207 | file: chunkFile, 208 | message: JSON.stringify(metadata), // Send metadata as the message. 209 | channelId: ChannelStore.getChannelId() 210 | }); 211 | 212 | setProgress(Math.round(((i + 1) / totalChunks) * 100)); 213 | } 214 | 215 | setStatus(`Successfully uploaded ${totalChunks} parts for ${file.name}`); 216 | } catch (error) { 217 | setStatus(`Error: ${error.message}`); 218 | } finally { 219 | setIsUploading(false); 220 | setProgress(0); 221 | } 222 | }, []); 223 | 224 | /** 225 | * Handler for the file input change event. 226 | * @param e The React change event from the file input. 227 | */ 228 | const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { 229 | const file = e.target.files?.[0]; 230 | if (!file) return; 231 | 232 | // Pre-flight check: Enforce Discord's absolute 500MB (Nitro) file size limit. 233 | if (file.size > 500 * 1024 * 1024) { 234 | setStatus("File exceeds 500MB. This is not supported."); 235 | return; 236 | } 237 | 238 | // Only split if the file is larger than our defined CHUNK_SIZE. 239 | if (file.size > CHUNK_SIZE) { 240 | setStatus(`Splitting ${file.name} into ~${Math.ceil(file.size / CHUNK_SIZE)} chunks...`); 241 | await handleFileSplit(file); 242 | } else { 243 | setStatus("File is small enough to be sent directly."); 244 | } 245 | 246 | // Reset the file input to allow re-selection of the same file. 247 | e.target.value = ""; 248 | }, [handleFileSplit]); 249 | 250 | // UI Note: Using a standard
for more flexible layout injection. 251 | return ( 252 |
253 | 259 | 265 | {status && {status}} 266 |
267 | ); 268 | }; 269 | 270 | // --- Vencord Plugin Definition --- 271 | 272 | export default definePlugin({ 273 | name: "FileSplitter", 274 | description: "Splits large files into 25MB chunks to bypass Discord's default limit.", 275 | authors: [ 276 | { 277 | id: 1234567890n, 278 | name: "Your Name", 279 | }, 280 | ], 281 | 282 | // This property is used to store the interval ID for cleanup. 283 | chunkCleanupInterval: null as NodeJS.Timeout | null, 284 | 285 | /** 286 | * Handler for the 'MESSAGE_CREATE' dispatch event. 287 | * Intercepts incoming messages to find and assemble chunks. 288 | * @param { message: NsUI.Message } The message payload from Discord. 289 | */ 290 | onMessageCreate({ message }: { message: NsUI.Message }) { 291 | try { 292 | // Optimization: If there's no content or no attachment, it can't be a chunk. 293 | if (!message.content || !message.attachments?.length) return; 294 | 295 | const chunkData = JSON.parse(message.content); 296 | 297 | // Validate if this message is one of our file chunks. 298 | if (isValidChunk(chunkData)) { 299 | const attachment = message.attachments[0]; 300 | if (!attachment?.url) return; // Should not happen, but safeguard. 301 | 302 | const storedChunk: StoredFileChunk = { 303 | ...chunkData, 304 | url: attachment.url 305 | }; 306 | 307 | ChunkManager.addChunk(storedChunk); 308 | 309 | // Check if all chunks have been received. 310 | const chunks = ChunkManager.getChunks(chunkData.originalName); 311 | if (chunks && chunks.length === chunkData.total) { 312 | console.log(`[FileSplitter] All ${chunkData.total} chunks received for ${chunkData.originalName}. Initiating merge...`); 313 | 314 | // Optimization TODO: Implement a user-facing prompt (e.g., a modal or toast) 315 | // to confirm file merge, rather than automatic download. 316 | 317 | // Current: Initiates automatic merge and download upon receiving the final chunk. 318 | handleFileMerge(chunks); 319 | } 320 | } 321 | } catch (e) { 322 | // Gracefully handle non-chunk messages; JSON.parse will fail, which is expected. 323 | } 324 | }, 325 | 326 | start() { 327 | // Initiate periodic garbage collection for expired chunk data. 328 | this.chunkCleanupInterval = setInterval(() => { 329 | ChunkManager.cleanOldChunks(); 330 | }, 60000); // Run every 60 seconds. 331 | 332 | // 1. Subscribe to the 'MESSAGE_CREATE' event to intercept incoming messages. 333 | Dispatcher.subscribe("MESSAGE_CREATE", this.onMessageCreate); 334 | 335 | // 2. Inject the React component into the UI using Patcher. 336 | // We patch ChannelTextArea as it's a stable component rendered at the bottom of the chat. 337 | if (ChannelTextArea) { 338 | Patcher.after("FileSplitter", ChannelTextArea, "type", (thisObj, [props], res) => { 339 | // 'res' is the rendered ChannelTextArea element. 340 | // We append our component to its children. 341 | if (res?.props?.children && Array.isArray(res.props.children)) { 342 | res.props.children.push(); 343 | } 344 | }); 345 | } else { 346 | console.error("[FileSplitter] Failed to find ChannelTextArea component for patching UI."); 347 | } 348 | }, 349 | 350 | stop() { 351 | // Perform complete cleanup: remove all patches and event subscriptions. 352 | Patcher.unpatchAll("FileSplitter"); 353 | Dispatcher.unsubscribe("MESSAGE_CREATE", this.onMessageCreate); 354 | if (this.chunkCleanupInterval) { 355 | clearInterval(this.chunkCleanupInterval); 356 | } 357 | } 358 | }); 359 | --------------------------------------------------------------------------------