disk_kit_macos 1.2.2
disk_kit_macos: ^1.2.2 copied to clipboard
macOS platform implementation for DiskKit using native macOS APIs.
import 'dart:async';
import 'package:disk_kit/disk_kit.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() => runApp(const MyApp());
/// A manual test harness for the public DiskKit API.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'DiskKit example',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const DiskKitExample(),
);
}
class DiskKitExample extends StatefulWidget {
const DiskKitExample({super.key});
@override
State<DiskKitExample> createState() => _DiskKitExampleState();
}
class _DiskKitExampleState extends State<DiskKitExample> {
final _kit = const DiskKit();
final _relativePath = TextEditingController();
final _localPath = TextEditingController();
final _volumeName = TextEditingController();
final _confirmation = TextEditingController();
final _imagePath = TextEditingController();
final _wimlibPath = TextEditingController();
MediaOperationProgress? _mediaProgress;
late final StreamSubscription<List<DiskInfo>> _subscription;
List<DiskInfo> _disks = [];
String? _error;
bool _loading = true;
bool _showInternal = false;
bool _showDetails = false;
bool _running = false;
@override
void initState() {
super.initState();
_subscription = _kit.watchDisks().listen(
(disks) {
if (!mounted) return;
setState(() {
_disks = disks;
_loading = false;
_error = null;
});
},
onError: (Object error) {
if (!mounted) return;
setState(() {
_error = error.toString();
_loading = false;
});
},
);
}
@override
void dispose() {
unawaited(_subscription.cancel());
_relativePath.dispose();
_localPath.dispose();
_volumeName.dispose();
_confirmation.dispose();
_imagePath.dispose();
_wimlibPath.dispose();
super.dispose();
}
Future<void> _refresh() async {
try {
final disks = await _kit.getDisks();
if (mounted) {
setState(() {
_disks = disks;
_loading = false;
_error = null;
});
}
} catch (error) {
if (mounted) {
setState(() {
_error = error.toString();
_loading = false;
});
}
}
}
Future<void> _run(String label, Future<void> Function() operation) async {
setState(() {
_running = true;
_mediaProgress = null;
});
try {
await operation();
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$label completed')));
}
} catch (error) {
final message = error is PlatformException
? '${error.code}: ${error.message}'
: error.toString();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 8),
),
);
}
} finally {
if (mounted) {
setState(() {
_running = false;
_mediaProgress = null;
});
await _refresh();
}
}
}
Future<void> _copy(DiskInfo disk, {required bool fromDisk}) async {
final relative = _relativePath..clear();
final local = _localPath..clear();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(fromDisk ? 'Copy from ${disk.id}' : 'Copy to ${disk.id}'),
content: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Mounted at ${disk.volumePath}\nThe destination must not exist. Parent directories must exist.',
),
const SizedBox(height: 16),
TextField(
controller: relative,
decoration: const InputDecoration(
labelText: 'Relative path inside the volume',
hintText: 'Documents/report.pdf',
),
),
TextField(
controller: local,
decoration: InputDecoration(
labelText: fromDisk
? 'Absolute destination path on this Mac'
: 'Absolute source path on this Mac',
hintText: '/Users/you/Desktop/report.pdf',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Copy'),
),
],
),
);
final relativePath = relative.text;
final localPath = local.text;
if (confirmed != true || !mounted) return;
await _run(
'Copy',
() => fromDisk
? _kit.copyFromDisk(
disk.id,
relativePath: relativePath,
destinationPath: localPath,
)
: _kit.copyToDisk(
disk.id,
sourcePath: localPath,
relativePath: relativePath,
),
);
}
Future<void> _prepareMedia(DiskInfo disk) async {
final image = _imagePath..clear();
final wimlib = _wimlibPath..clear();
final confirmation = _confirmation..clear();
var mode = 'raw';
var verify = true;
var elevate = true;
final expected = 'ERASE ${disk.id}';
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, update) => AlertDialog(
title: const Text('Create installation media'),
content: SizedBox(
width: 560,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${disk.name ?? disk.id} · ${_size(disk.sizeBytes)}\n'
'${disk.devicePath}\nAll partitions and files will be erased.'),
const SizedBox(height: 16),
DropdownButton<String>(
value: mode,
isExpanded: true,
items: const [
DropdownMenuItem(
value: 'raw',
child: Text('Raw IMG / hybrid ISO (e.g. Ubuntu)')),
DropdownMenuItem(
value: 'windows',
child: Text('Windows ISO → UEFI installer')),
DropdownMenuItem(
value: 'macos',
child: Text('Install macOS .app → Mac installer')),
],
onChanged: (value) => update(() => mode = value!),
),
Text(mode == 'raw'
? 'Use an uncompressed IMG or USB-compatible hybrid ISO. Ordinary Windows ISOs use the Windows mode.'
: mode == 'windows'
? 'Creates MBR/FAT32 for UEFI; legacy BIOS is unsupported. Large install.wim needs wimlib and local temporary space. Large install.esd is unsupported.'
: 'Requires a full Apple installer app and administrator permission. The selected macOS version must support the host and destination Mac.'),
TextField(
controller: image,
onChanged: (_) => update(() {}),
decoration: InputDecoration(
labelText: mode == 'macos'
? 'Absolute installer app path'
: 'Absolute image path',
hintText: mode == 'macos'
? '/Applications/Install macOS Sequoia.app'
: '/Users/you/Downloads/installer.iso',
),
),
if (mode == 'windows')
TextField(
controller: wimlib,
decoration: const InputDecoration(
labelText: 'Optional absolute wimlib-imagex path',
hintText: '/opt/homebrew/bin/wimlib-imagex',
),
),
if (mode != 'macos')
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Verify written data'),
value: verify,
onChanged: (value) => update(() => verify = value!),
),
if (mode != 'windows')
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Allow system administrator prompt'),
value: elevate,
onChanged: (value) => update(() => elevate = value!),
),
const Text(
'Keep the source on another disk. A failure can leave partially written media. Do not disconnect the USB drive during writing. Boot compatibility depends on the image and destination computer.'),
TextField(
controller: confirmation,
onChanged: (_) => update(() {}),
decoration:
InputDecoration(labelText: 'Type $expected to confirm'),
),
],
)),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
FilledButton(
onPressed:
confirmation.text == expected && image.text.trim().isNotEmpty
? () => Navigator.pop(context, true)
: null,
child: const Text('Erase and write'),
),
],
),
),
);
if (confirmed != true || !mounted) return;
void progress(MediaOperationProgress update) {
if (mounted) setState(() => _mediaProgress = update);
}
await _run(
'Installation media',
() => switch (mode) {
'windows' => _kit.createWindowsInstaller(disk.id,
isoPath: image.text.trim(),
wimlibPath:
wimlib.text.trim().isEmpty ? null : wimlib.text.trim(),
verify: verify,
onProgress: progress),
'macos' => _kit.createMacOSInstaller(disk.id,
installerAppPath: image.text.trim(),
allowElevation: elevate,
onProgress: progress),
_ => _kit.writeImage(disk.id,
imagePath: image.text.trim(),
verify: verify,
allowElevation: elevate,
onProgress: progress),
});
}
Future<void> _rename(DiskInfo disk) async {
final name = _volumeName..text = disk.volumeName ?? '';
final newName = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Rename volume'),
content: SizedBox(
width: 420,
child: TextField(
controller: name,
autofocus: true,
decoration: const InputDecoration(labelText: 'New volume name'),
onSubmitted: (value) => Navigator.pop(context, value),
)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, name.text),
child: const Text('Rename')),
],
),
);
if (newName == null || !mounted) return;
await _run('Rename', () => _kit.renameVolume(disk.id, volumeName: newName));
}
Future<void> _format(DiskInfo disk) async {
final name = _volumeName..text = 'DISKKIT';
final confirmation = _confirmation..clear();
var fs = DiskFileSystem.exFat;
var scheme = DiskPartitionScheme.gpt;
final whole = disk.isWholeDisk == true;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, update) => AlertDialog(
title: Text('Erase ${whole ? 'entire disk' : 'volume'} ${disk.id}'),
content: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
whole
? 'All partitions and their data will be deleted.'
: 'All data on this volume will be deleted.',
),
DropdownButton<DiskFileSystem>(
value: fs,
isExpanded: true,
items: DiskFileSystem.values
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(switch (value) {
DiskFileSystem.exFat => 'exFAT',
DiskFileSystem.fat32 => 'FAT32',
DiskFileSystem.apfs => 'APFS',
DiskFileSystem.hfsPlus =>
'HFS+ (Mac OS Extended, Journaled)',
DiskFileSystem.apfsCaseSensitive =>
'APFS (Case-sensitive)',
DiskFileSystem.hfsPlusNonJournaled =>
'HFS+ (Mac OS Extended)',
DiskFileSystem.hfsPlusCaseSensitive =>
'HFS+ (Case-sensitive)',
DiskFileSystem.hfsPlusCaseSensitiveJournaled =>
'HFS+ (Case-sensitive, Journaled)',
}),
),
)
.toList(),
onChanged: (value) => update(() {
fs = value!;
if (fs.requiresGpt) {
scheme = DiskPartitionScheme.gpt;
}
}),
),
if (whole)
DropdownButton<DiskPartitionScheme>(
value: scheme,
isExpanded: true,
items: DiskPartitionScheme.values
.where(
(value) =>
!fs.requiresGpt ||
value == DiskPartitionScheme.gpt,
)
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value.name.toUpperCase()),
),
)
.toList(),
onChanged: (value) => update(() => scheme = value!),
),
TextField(
controller: name,
decoration: const InputDecoration(
labelText: 'New volume name',
),
),
TextField(
controller: confirmation,
onChanged: (_) => update(() {}),
decoration: InputDecoration(
labelText: 'Type ERASE ${disk.id} to confirm',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: confirmation.text == 'ERASE ${disk.id}'
? () => Navigator.pop(context, true)
: null,
child: const Text('Erase and format'),
),
],
),
),
);
final volumeName = name.text;
if (confirmed != true || !mounted) return;
await _run(
'Format',
() => whole
? _kit.formatDisk(
disk.id,
fileSystem: fs,
volumeName: volumeName,
partitionScheme: scheme,
)
: _kit.formatVolume(disk.id, fileSystem: fs, volumeName: volumeName),
);
}
bool _isSystemPartition(DiskInfo disk) {
final content = disk.mediaContent?.toLowerCase();
return content == 'efi' ||
content == 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b';
}
String _size(int? bytes) {
if (bytes == null) return 'Unknown size';
for (final unit in [
(1000000000000, 'TB'),
(1000000000, 'GB'),
(1000000, 'MB')
]) {
if (bytes >= unit.$1) {
return '${(bytes / unit.$1).toStringAsFixed(1)} ${unit.$2}';
}
}
return '$bytes bytes';
}
String _fileSystem(DiskInfo disk) => switch (disk.fileSystem) {
'exfat' => 'exFAT',
'msdos' => 'FAT',
'apfs' => 'APFS',
'hfs' => 'HFS+',
final value => value ?? 'No filesystem',
};
Widget _volume(DiskInfo disk) {
final system = _isSystemPartition(disk);
final external = disk.isInternal == false;
return Padding(
key: ValueKey('volume-${disk.id}'),
padding: const EdgeInsets.only(top: 16, left: 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(system ? Icons.settings : Icons.folder_outlined, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(disk.volumeName ?? disk.name ?? 'Unnamed volume',
style: Theme.of(context).textTheme.titleSmall)),
]),
Text(
'${_fileSystem(disk)} · ${_size(disk.sizeBytes)} · ${system ? 'System partition' : disk.isMounted ? 'Mounted' : 'Unmounted'}'),
if (disk.volumePath != null) SelectableText(disk.volumePath!),
if (_showDetails)
SelectableText('${disk.id} · ${disk.devicePath}\n'
'Partition type: ${disk.mediaContent ?? 'Unknown'}\n'
'UUID: ${disk.volumeUuid ?? disk.mediaUuid ?? 'Unknown'}'),
if (external && !system) ...[
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: [
if (!disk.isMounted && disk.isMountable == true)
OutlinedButton(
onPressed: _running
? null
: () => _run('Mount', () => _kit.mount(disk.id)),
child: const Text('Mount')),
if (disk.isMounted) ...[
OutlinedButton(
onPressed:
_running ? null : () => _copy(disk, fromDisk: true),
child: const Text('Copy from disk')),
OutlinedButton(
onPressed:
_running ? null : () => _copy(disk, fromDisk: false),
child: const Text('Copy to disk')),
OutlinedButton(
onPressed: _running
? null
: () => _run('Unmount', () => _kit.unmount(disk.id)),
child: const Text('Unmount')),
],
if (disk.isMountable == true || disk.volumeName != null)
OutlinedButton(
onPressed: _running ? null : () => _rename(disk),
child: const Text('Rename…')),
if (disk.isWholeDisk == false)
OutlinedButton(
onPressed: _running ? null : () => _format(disk),
child: const Text('Format volume…')),
]),
],
]),
);
}
Widget _deviceCard(String id, List<DiskInfo> entries) {
final whole = entries
.where((disk) => disk.id == id && disk.isWholeDisk == true)
.firstOrNull;
final volumes = entries
.where((disk) =>
disk != whole || disk.isMountable == true || disk.isMounted)
.where((disk) => _showDetails || !_isSystemPartition(disk))
.toList();
return Card(
key: ValueKey('device-$id'),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.storage),
const SizedBox(width: 12),
Expanded(
child: Text(
whole?.name ?? whole?.model ?? 'Storage device ($id)',
style: Theme.of(context).textTheme.titleMedium)),
]),
if (whole != null) ...[
Text(
'${_size(whole.sizeBytes)} · ${whole.busProtocol ?? 'Unknown connection'}'),
if (_showDetails)
SelectableText(
'${whole.id} · ${whole.devicePath}\nUUID: ${whole.mediaUuid ?? 'Unknown'}'),
if (whole.isInternal == false) ...[
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [
OutlinedButton(
onPressed: _running
? null
: () => _run('Unmount disk',
() => _kit.unmount(whole.id, wholeDisk: true)),
child: const Text('Unmount all volumes')),
OutlinedButton(
onPressed: _running
? null
: () => _run('Eject', () => _kit.eject(whole.id)),
child: const Text('Eject')),
OutlinedButton(
onPressed: _running || whole.isWritable != true
? null
: () => _prepareMedia(whole),
child: const Text('Write image / installer…')),
FilledButton(
onPressed: _running ? null : () => _format(whole),
child: const Text('Format disk…')),
]),
],
],
if (volumes.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(),
...volumes.map(_volume),
] else
const Padding(
padding: EdgeInsets.only(top: 16),
child: Text('No data volumes.')),
],
)),
);
}
@override
Widget build(BuildContext context) {
final groups = <String, List<DiskInfo>>{};
for (final disk in _disks) {
final id = disk.wholeDiskId ?? disk.id;
(groups[id] ??= []).add(disk);
}
final visible = groups.entries
.where((group) =>
_showInternal ||
group.value.any((disk) => disk.isInternal == false))
.toList();
return Scaffold(
appBar: AppBar(title: const Text('DiskKit • macOS'), actions: [
TextButton(
onPressed: _running ? null : _refresh,
child: const Text('Refresh')),
]),
body: Column(children: [
SwitchListTile(
title: const Text('Show internal and unknown disks'),
value: _showInternal,
onChanged: (value) => setState(() => _showInternal = value)),
SwitchListTile(
title: const Text('Show details and system partitions'),
value: _showDetails,
onChanged: (value) => setState(() => _showDetails = value)),
if (_running) LinearProgressIndicator(value: _mediaProgress?.fraction),
if (_running && _mediaProgress != null)
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'${_mediaProgress!.diskId} · ${_mediaProgress!.stage.name}'
'${_mediaProgress!.fraction == null ? "" : " · ${(_mediaProgress!.fraction! * 100).toStringAsFixed(1)}%"}'),
),
if (_error != null)
Padding(padding: const EdgeInsets.all(16), child: Text(_error!)),
if (_loading) const CircularProgressIndicator(),
if (!_loading && visible.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text('No external disks. Connect a USB drive.')),
Expanded(
child: ListView.builder(
itemCount: visible.length,
itemBuilder: (context, index) =>
_deviceCard(visible[index].key, visible[index].value))),
]),
);
}
}