Initial commit

This commit is contained in:
Alicia Moore
2026-06-22 22:05:39 -07:00
commit 9d35b244c2
5 changed files with 1106 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
olympuswifi
src/*.c
+18
View File
@@ -0,0 +1,18 @@
CC = valac
PKGS = --pkg gtk4 --pkg libadwaita-1 --pkg libsoup-2.4 --pkg gio-2.0 --pkg gdk-pixbuf-2.0 --pkg pango
SRCS = src/main.vala src/camera-client.vala
TARGET = olympuswifi
CFLAGS = -g
all: $(TARGET)
$(TARGET): $(SRCS)
$(CC) $(CFLAGS) $(PKGS) $(SRCS) -o $(TARGET)
run: $(TARGET)
./$(TARGET)
clean:
rm -f $(TARGET)
.PHONY: all run clean
Binary file not shown.
+412
View File
@@ -0,0 +1,412 @@
// Camera client for Olympus OPC protocol
// Communicates with camera at 192.168.0.10:80 via HTTP
public class ImageInfo : Object {
public string directory { get; set; }
public string filename { get; set; }
public int64 size { get; set; }
public int attribute { get; set; }
public DateTime? date_time { get; set; }
public string full_path { get; set; }
public bool is_hidden {
get { return (attribute & 2) != 0; }
}
public bool is_directory {
get { return (attribute & 16) != 0; }
}
public string display_date {
owned get {
if (date_time == null) return "Unknown";
return date_time.format ("%Y-%m-%d %H:%M");
}
}
public ImageInfo (string directory, string filename, int64 size, int attribute,
DateTime? date_time, string full_path) {
this.directory = directory;
this.filename = filename;
this.size = size;
this.attribute = attribute;
this.date_time = date_time;
this.full_path = full_path;
}
}
public class CameraClient : Object {
private Soup.Session session;
private MainContext? context = null;
private bool owns_context = false;
private const string CAMERA_HOST = "192.168.0.10";
private const int CAMERA_PORT = 80;
private const int TIMEOUT_SECONDS = 5;
public bool connected { get; private set; default = false; }
public static bool debug { get; set; default = false; }
public CameraClient () {
// Worker threads typically have no thread-default GMainContext.
// Soup.Session.send_message internally iterates a GMainContext,
// so we must push one to avoid crashes with NULL context.
context = MainContext.get_thread_default ();
if (context == null) {
context = new MainContext ();
context.push_thread_default ();
owns_context = true;
}
session = new Soup.Session ();
session.user_agent = "OI.Share v2";
session.timeout = TIMEOUT_SECONDS;
}
~CameraClient () {
if (owns_context && context != null) {
context.pop_thread_default ();
}
}
private string build_url (string path) {
return "http://%s:%d%s".printf( CAMERA_HOST, CAMERA_PORT, path);
}
// Perform synchronous HTTP GET, return response body as Bytes
private Bytes do_get (string url) throws Error {
if (debug) {
stdout.printf (">>> GET %s\n", url);
}
var msg = new Soup.Message ("GET", url);
session.send_message (msg);
if (debug) {
stdout.printf ("<<< HTTP %u %s\n", msg.status_code, msg.reason_phrase);
}
if (msg.status_code != 200) {
if (debug) {
stdout.printf ("<<< ERROR: HTTP %u - body follows:\n", msg.status_code);
unowned uint8[] err_raw = msg.response_body.data;
if (err_raw != null) {
var err_body = (string) err_raw;
if (((string) err_body).validate ())
stdout.printf ("%s\n", (string) err_body);
}
}
throw new IOError.FAILED (
"HTTP request to %s failed with status %u", url, msg.status_code
);
}
// Extract response body as Bytes.
// Use unowned access to avoid an extra intermediate copy.
unowned uint8[] raw_data = msg.response_body.data;
var bytes = new Bytes (raw_data);
if (debug) {
if (raw_data != null) {
var body_str = (string) raw_data;
if (((string) body_str).validate ()) {
stdout.printf ("<<< Body (%ld bytes):\n%s\n", raw_data.length, (string) body_str);
} else {
stdout.printf ("<<< Body: %ld bytes (binary data)\n", raw_data.length);
}
} else {
stdout.printf ("<<< Body: 0 bytes (empty)\n");
}
}
return bytes;
}
// --- Public API ---
// Probe camera by fetching its command list.
// Modern Olympus cameras don't necessarily return OPC mode from
// get_connectmode.cgi; instead they respond to get_commandlist.cgi
// directly. This matches the approach used by the working
// olympus-wifi library (OI.Share v2 user-agent).
public bool check_connection( ) throws Error {
if (debug) {
stdout.printf ("[debug] Probing camera with get_commandlist.cgi…\n");
}
try {
var body = do_get (build_url ("/get_commandlist.cgi"));
string xml = (string)body.get_data ();
if (xml.contains ("<?xml") && xml.contains( "<cgi")) {
connected = true;
if (debug) {
stdout.printf ("[debug] Camera responded with valid command list, connected.\n");
}
return true;
}
if (debug) {
stdout.printf ("[debug] Unexpected response from get_commandlist:\n%s\n", xml);
}
} catch (Error e) {
if (debug) {
stdout.printf ("[debug] get_commandlist failed: %s\n", e.message);
}
}
// Fallback: try get_connectmode.cgi (older cameras)
if (debug) {
stdout.printf ("[debug] Falling back to get_connectmode.cgi…\n");
}
try {
var body = do_get (build_url ("/get_connectmode.cgi"));
string xml = (string)body.get_data ();
if (xml.contains ("<connectmode>OPC</connectmode>")) {
connected = true;
return true;
}
if (debug) {
stdout.printf ("[debug] get_connectmode returned: %s\n", xml.replace( "\n", " ").strip( ));
}
} catch (Error e) {
if (debug) {
stdout.printf ("[debug] get_connectmode failed: %s\n", e.message);
}
}
return false;
}
// Switch operation mode (play, rec)
// Note: modern Olympus cameras use "switch_cammode.cgi" (one 'm')
// not "switch_cameramode.cgi" as in the older OPC spec.
public bool switch_mode( string mode) throws Error {
string url = build_url ("/switch_cammode.cgi?mode=%s".printf( mode));
var body = do_get (url);
string xml = (string)body.get_data ();
if (debug) {
stdout.printf ("[debug] switch_cammode response: %s\n", xml.replace( "\n", " ").strip( ));
}
// Some cameras return <result>OK, some just return HTTP 200 with empty body
return true;
}
// Activate session (some cameras require this before other commands)
public void activate( ) throws Error {
try {
do_get (build_url ("/get_activate.cgi"));
if (debug) {
stdout.printf ("[debug] get_activate succeeded\n");
}
} catch (Error e) {
if (debug) {
stdout.printf ("[debug] get_activate failed (non-fatal): %s\n", e.message);
}
}
}
// Get list of items in a DCF directory
// Returns an array of ImageInfo
// Note: 404 means empty directory on modern cameras, returns empty list.
public ImageInfo[] get_image_list( string dir) throws Error {
string url = build_url ("/get_imglist.cgi?DIR=%s".printf( dir));
Bytes body;
try {
body = do_get (url);
} catch (Error e) {
if (e.message.contains ("status 404")) {
if (debug) {
stdout.printf ("[debug] get_imglist(%s): 404 (empty directory)\n", dir);
}
return new ImageInfo[0];
}
throw e;
}
string text = (string)body.get_data ();
string[] lines = text.split ("\n");
if (debug) {
stdout.printf ("[debug] get_imglist(%s) response (%lu bytes):\n", dir, body.get_size( ));
// Print first few lines for debugging
for (int i = 0; i < lines.length && i < 5; i++) {
stdout.printf ("[debug] line %d: %s\n", i, lines[i]);
}
}
var results = new ImageInfo[lines.length - 1]; // max possible
int count = 0;
for (int i = 0; i < lines.length; i++) {
string line = lines[i].strip ();
if (line.length == 0 || line == "VER_100") continue;
string[] parts = line.split (",", 6);
if (parts.length < 6) continue;
string directory = parts[0];
string filename = parts[1];
int64 size = int64.parse (parts[2]);
int attribute = int.parse (parts[3]);
string full_path = "%s/%s".printf( directory, filename);
// Parse packed date
int64 packed_date = int64.parse( parts[4]);
int64 packed_time = int64.parse (parts[5]);
DateTime? dt = decode_opc_date (packed_date, packed_time);
results[count] = new ImageInfo (directory, filename, size, attribute, dt, full_path);
count++;
}
// Trim to actual count
var trimmed = new ImageInfo[count];
for (int i = 0; i < count; i++) {
trimmed[i] = results[i];
}
return trimmed;
}
// Discover all DCF directories on the camera.
// Probes multiple paths and tries common DCF directory names.
// Returns an array of directory paths that contain images.
public string[] discover_dcf_directories( ) throws Error {
string[] found_dirs = new string[0];
// Probe known DCF paths that could contain subdirectories
string[] probe_paths = {"/", "/DCIM/"};
int count = 0;
foreach (string probe in probe_paths) {
try {
var items = get_image_list (probe);
// Make a first pass to count subdirectories
int sub_count = 0;
foreach (var item in items) {
if (item.is_directory && !item.is_hidden) {
sub_count++;
}
}
if (sub_count > 0) {
found_dirs = new string[sub_count];
int idx = 0;
foreach (var item in items) {
if (item.is_directory && !item.is_hidden) {
found_dirs[idx] = item.full_path;
idx++;
if (debug) {
stdout.printf ("[debug] Discovered DCF dir: %s\n", item.full_path);
}
}
}
count = sub_count;
break;
}
} catch (Error e) {
if (debug) {
stdout.printf ("[debug] Probe %s: %s\n", probe, e.message);
}
}
}
// If probe paths yielded directories, return them
if (count > 0) {
return found_dirs;
}
// Fallback: try standard DCF directory names directly
string[] fallbacks = {"/DCIM/100OLYMP", "/DCIM/101OLYMP", "/DCIM/102OLYMP",
"/DCIM/103OLYMP", "/DCIM/104OLYMP", "/DCIM/105OLYMP"};
int fb_count = 0;
foreach (string d in fallbacks) {
try {
get_image_list (d);
fb_count++;
} catch (Error e) {
// doesn't exist, skip
}
}
if (fb_count > 0) {
found_dirs = new string[fb_count];
int idx = 0;
foreach (string d in fallbacks) {
try {
get_image_list (d);
found_dirs[idx] = d;
idx++;
if (debug) {
stdout.printf ("[debug] Found DCF directory (fallback): %s\n", d);
}
} catch (Error e) {
}
}
return found_dirs;
}
if (debug) {
stdout.printf ("[debug] No DCF directories found - camera may have no SD card\n");
}
return found_dirs;
}
// Get all images across all DCF directories
public ImageInfo[] get_all_images( ) throws Error {
var dirs = discover_dcf_directories ();
// First pass: count total images
int total = 0;
foreach (string dir in dirs) {
var images = get_image_list (dir);
foreach (var img in images) {
if (!img.is_directory && !img.is_hidden) {
total++;
}
}
}
var all = new ImageInfo[total];
int idx = 0;
foreach (string dir in dirs) {
var images = get_image_list (dir);
foreach (var img in images) {
if (!img.is_directory && !img.is_hidden) {
all[idx] = img;
idx++;
}
}
}
return all;
}
// Get thumbnail image (160x120 JPEG)
public Bytes get_thumbnail( string path) throws Error {
string url = build_url ("/get_thumbnail.cgi?DIR=%s".printf( path));
return do_get (url);
}
// Get image for control device display (1920x1440 max)
public Bytes get_screennail( string path) throws Error {
string url = build_url ("/get_screennail.cgi?DIR=%s".printf( path));
return do_get (url);
}
// Download original image file from camera
public Bytes download_original( string path) throws Error {
return do_get (build_url (path));
}
}
// Helper to decode packed date integer from OPC protocol
// bits 4-0: day, bits 8-5: month, bits 15-9: year (offset from 1980)
DateTime? decode_opc_date( int64 packed_date, int64 packed_time) {
int day = (int)(packed_date & 0x1F);
int month = (int)((packed_date >> 5) & 0x0F);
int year = (int)((packed_date >> 9) + 1980);
int seconds = (int)(packed_time & 0x1F) * 2;
int minutes = (int)((packed_time >> 5) & 0x3F);
int hours = (int)((packed_time >> 11) & 0x1F);
if (year < 1980 || month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
return new DateTime.local (year, month, day, hours, minutes, seconds);
}
+674
View File
@@ -0,0 +1,674 @@
// Olympus Wi-Fi Camera Viewer
// GTK4 + Libadwaita application for browsing and importing images
// from Olympus cameras using the OPC protocol
public class ImageItem : Gtk.FlowBoxChild {
private Gtk.Image thumbnail_image;
private Gtk.Label name_label;
private Gtk.Label date_label;
private Gtk.Box main_box;
private Gtk.Overlay overlay;
private Gtk.CheckButton check_button;
public ImageInfo info { get; construct; }
public bool selected {
get { return check_button.active; }
set { check_button.active = value; }
}
public signal void selection_changed ();
public ImageItem (ImageInfo info) {
Object (info: info);
}
construct {
hexpand = true;
halign = Gtk.Align.CENTER;
overlay = new Gtk.Overlay ();
main_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 4);
main_box.margin_start = 4;
main_box.margin_end = 4;
main_box.margin_top = 4;
main_box.margin_bottom = 4;
// Thumbnail image
thumbnail_image = new Gtk.Image( );
thumbnail_image.pixel_size = 160;
thumbnail_image.icon_name = "image-x-generic";
thumbnail_image.halign = Gtk.Align.CENTER;
thumbnail_image.valign = Gtk.Align.CENTER;
var image_frame = new Gtk.Frame (null);
image_frame.child = thumbnail_image;
image_frame.width_request = 160;
image_frame.height_request = 120;
main_box.append (image_frame);
// File name
name_label = new Gtk.Label( info.filename);
name_label.ellipsize = Pango.EllipsizeMode.END;
name_label.max_width_chars = 20;
name_label.halign = Gtk.Align.CENTER;
var attr_list = new Pango.AttrList ();
attr_list.insert (Pango.attr_weight_new (Pango.Weight.BOLD));
name_label.attributes = attr_list;
main_box.append (name_label);
// Date
date_label = new Gtk.Label( info.display_date);
date_label.ellipsize = Pango.EllipsizeMode.END;
date_label.max_width_chars = 20;
date_label.halign = Gtk.Align.CENTER;
date_label.opacity = 0.7;
main_box.append (date_label);
overlay.child = main_box;
// Check button overlay for selection (top-left corner)
check_button = new Gtk.CheckButton( );
check_button.halign = Gtk.Align.START;
check_button.valign = Gtk.Align.START;
check_button.margin_start = 8;
check_button.margin_top = 8;
check_button.add_css_class ("selection-mode");
overlay.add_overlay (check_button);
child = overlay;
check_button.toggled.connect (() => {
update_selection_style ();
selection_changed ();
});
// Click toggles selection
var gesture = new Gtk.GestureClick( );
gesture.pressed.connect ((n_press, x, y) => {
if (n_press == 1) {
check_button.active = !check_button.active;
}
});
add_controller (gesture);
add_css_class ("image-item");
}
private void update_selection_style () {
if (check_button.active) {
add_css_class ("selected-image");
} else {
remove_css_class ("selected-image");
}
}
public void set_thumbnail_from_bytes (Bytes bytes) {
try {
var texture = Gdk.Texture.from_bytes (bytes);
if (texture != null) {
thumbnail_image.set_from_paintable (texture);
} else {
thumbnail_image.icon_name = "image-x-generic";
}
} catch (Error e) {
warning ("Failed to load thumbnail: %s", e.message);
thumbnail_image.icon_name = "image-x-generic";
}
}
}
public class ConnectPage : Gtk.Box {
private Gtk.Button connect_button;
private Gtk.Label status_label;
private Gtk.Spinner spinner;
private Gtk.Box status_box;
public signal void connection_succeeded ();
public ConnectPage () {
Object (
orientation: Gtk.Orientation.VERTICAL,
spacing: 16,
halign: Gtk.Align.CENTER,
valign: Gtk.Align.CENTER
);
}
construct {
var title_label = new Gtk.Label ("Olympus Wi-Fi Camera");
var title_attrs = new Pango.AttrList ();
title_attrs.insert (Pango.attr_scale_new (Pango.Scale.XX_LARGE));
title_attrs.insert (Pango.attr_weight_new (Pango.Weight.BOLD));
title_label.attributes = title_attrs;
title_label.add_css_class ("title-label");
append (title_label);
var subtitle_label = new Gtk.Label ("Browse and import photos from your Olympus camera");
subtitle_label.opacity = 0.7;
append (subtitle_label);
append (new Gtk.Label (""));
// Connect button
connect_button = new Gtk.Button.with_label( "Connect to Camera");
connect_button.add_css_class ("suggested-action");
connect_button.add_css_class ("circular");
connect_button.hexpand = false;
connect_button.width_request = 250;
connect_button.height_request = 60;
connect_button.valign = Gtk.Align.CENTER;
var button_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
button_box.halign = Gtk.Align.CENTER;
button_box.append (connect_button);
append (button_box);
// Status box (spinner + label)
status_box = new Gtk.Box( Gtk.Orientation.HORIZONTAL, 8);
status_box.halign = Gtk.Align.CENTER;
status_box.visible = false;
spinner = new Gtk.Spinner ();
status_box.append (spinner);
status_label = new Gtk.Label ("");
status_box.append (status_label);
append (status_box);
connect_button.clicked.connect (on_connect_clicked);
}
private void on_connect_clicked () {
set_connecting_state ("Connecting to camera…");
connect_button.sensitive = false;
new Thread<void*> ("connect", connect_thread);
}
private void* connect_thread () {
var camera = new CameraClient ();
try {
// Step 1: Check/establish OPC connection
Idle.add( () => {
status_label.label = "Establishing OPC connection…";
return false;
});
Thread.usleep (100000);
if (!camera.check_connection ()) {
string msg = "Failed: Camera is not responding with OPC protocol.";
Idle.add (() => {
set_idle_state (msg);
connect_button.sensitive = true;
return false;
});
return null;
}
// Step 2: Activate session (some cameras require this)
Idle.add( () => {
status_label.label = "Activating session…";
return false;
});
Thread.usleep (100000);
camera.activate ();
// Step 3: Switch to playback mode
Idle.add( () => {
status_label.label = "Switching to playback mode…";
return false;
});
Thread.usleep (100000);
if (!camera.switch_mode ("play")) {
string msg = "Failed to switch camera to playback mode.";
Idle.add (() => {
set_idle_state (msg);
connect_button.sensitive = true;
return false;
});
return null;
}
Idle.add (() => {
set_idle_state ("Connected! Loading gallery…");
connect_button.sensitive = true;
connection_succeeded ();
return false;
});
} catch (Error connect_err) {
string msg = "Connection failed: %s".printf( connect_err.message);
Idle.add (() => {
set_idle_state (msg);
connect_button.sensitive = true;
return false;
});
}
return null;
}
private void set_connecting_state (string message) {
status_box.visible = true;
spinner.start ();
status_label.label = message;
status_label.remove_css_class ("error");
status_label.remove_css_class ("success");
}
private void set_idle_state (string message) {
spinner.stop ();
status_label.label = message;
if (message.has_prefix ("Failed") || message.has_prefix( "Connection failed")) {
status_label.add_css_class ("error");
status_label.remove_css_class ("success");
} else if (message.has_prefix ("Connected")) {
status_label.add_css_class ("success");
status_label.remove_css_class ("error");
}
}
}
public class GalleryPage : Gtk.Box {
private Gtk.FlowBox flowbox;
private Gtk.Label status_label;
private Gtk.Button import_button;
private Gtk.Button disconnect_button;
private Gtk.ProgressBar progress_bar;
private Gtk.Box progress_box;
private Gtk.Label progress_label;
private ImageInfo[] all_images;
private ImageItem[] image_widgets;
public signal void disconnect_requested ();
public GalleryPage () {
Object (
orientation: Gtk.Orientation.VERTICAL,
spacing: 0
);
this.all_images = new ImageInfo[0];
this.image_widgets = new ImageItem[0];
}
construct {
// Toolbar
var toolbar_box = new Gtk.Box( Gtk.Orientation.HORIZONTAL, 8);
toolbar_box.margin_start = 12;
toolbar_box.margin_end = 12;
toolbar_box.margin_top = 8;
toolbar_box.margin_bottom = 8;
disconnect_button = new Gtk.Button.with_label ("Disconnect");
disconnect_button.add_css_class ("flat");
toolbar_box.append (disconnect_button);
var spacer = new Gtk.Label ("");
spacer.hexpand = true;
toolbar_box.append (spacer);
status_label = new Gtk.Label ("Loading images…");
status_label.halign = Gtk.Align.CENTER;
status_label.hexpand = true;
toolbar_box.append (status_label);
var spacer2 = new Gtk.Label ("");
spacer2.hexpand = true;
toolbar_box.append (spacer2);
import_button = new Gtk.Button.with_label ("Import Selected");
import_button.add_css_class ("suggested-action");
import_button.sensitive = false;
toolbar_box.append (import_button);
append (toolbar_box);
append (new Gtk.Separator (Gtk.Orientation.HORIZONTAL));
// Scrolled window with FlowBox
var scrolled = new Gtk.ScrolledWindow( );
scrolled.vexpand = true;
scrolled.hexpand = true;
flowbox = new Gtk.FlowBox ();
flowbox.max_children_per_line = 6;
flowbox.selection_mode = Gtk.SelectionMode.NONE;
flowbox.homogeneous = true;
flowbox.column_spacing = 8;
flowbox.row_spacing = 8;
flowbox.margin_start = 12;
flowbox.margin_end = 12;
flowbox.margin_top = 8;
flowbox.margin_bottom = 8;
flowbox.activate_on_single_click = false;
scrolled.child = flowbox;
append (scrolled);
// Progress box
progress_box = new Gtk.Box( Gtk.Orientation.VERTICAL, 4);
progress_box.margin_start = 12;
progress_box.margin_end = 12;
progress_box.margin_top = 8;
progress_box.margin_bottom = 8;
progress_box.visible = false;
progress_label = new Gtk.Label ("");
progress_box.append (progress_label);
progress_bar = new Gtk.ProgressBar ();
progress_bar.show_text = false;
progress_box.append (progress_bar);
append (progress_box);
disconnect_button.clicked.connect (() => { disconnect_requested (); });
import_button.clicked.connect (on_import_clicked);
load_images ();
}
private void load_images () {
status_label.label = "Loading images…";
import_button.sensitive = false;
new Thread<void*> ("load", load_images_thread);
}
private void* load_images_thread () {
// Create dedicated CameraClient in this thread so Soup.Session
// runs on a GMainContext owned by this thread (avoids segfault).
var load_camera = new CameraClient ();
try {
var images = load_camera.get_all_images ();
Idle.add (() => {
all_images = images;
populate_gallery ();
status_label.label = "%d images found".printf( all_images.length);
update_import_button_state ();
return false;
});
} catch (Error err) {
string msg = "Error: %s".printf( err.message);
Idle.add (() => {
status_label.label = msg;
return false;
});
}
return null;
}
private void populate_gallery () {
var child = flowbox.get_first_child ();
while (child != null) {
var next = child.get_next_sibling ();
flowbox.remove (child);
child = next;
}
image_widgets = new ImageItem[all_images.length];
for (int i = 0; i < all_images.length; i++) {
var info = all_images[i];
var item = new ImageItem (info);
item.selection_changed.connect (update_import_button_state);
flowbox.append (item);
image_widgets[i] = item;
load_thumbnail_async (item, info.full_path);
}
}
private void load_thumbnail_async (ImageItem item, string path) {
new Thread<void*> ("thumb", () => {
// Create a separate CameraClient per thread for thread safety
var thumb_camera = new CameraClient( );
try {
var bytes = thumb_camera.get_thumbnail (path);
Idle.add (() => {
item.set_thumbnail_from_bytes (bytes);
return false;
});
} catch (Error e) {
warning ("Thumbnail failed for %s: %s", path, e.message);
}
return null;
});
}
private void update_import_button_state () {
int count = 0;
for (int i = 0; i < image_widgets.length; i++) {
if (image_widgets[i].selected) count++;
}
import_button.sensitive = count > 0;
import_button.label = count > 0
? "Import Selected (%d)".printf( count)
: "Import Selected";
}
private void on_import_clicked () {
int count = 0;
for (int i = 0; i < image_widgets.length; i++) {
if (image_widgets[i].selected) count++;
}
if (count == 0) return;
var selected = new ImageInfo[count];
int idx = 0;
for (int i = 0; i < image_widgets.length; i++) {
if (image_widgets[i].selected) {
selected[idx] = image_widgets[i].info;
idx++;
}
}
var file_dialog = new Gtk.FileDialog ();
file_dialog.title = "Select Destination Directory";
file_dialog.select_folder.begin ((Gtk.Window?)this.get_root (), null, (obj, res) => {
try {
var dest = file_dialog.select_folder.end (res);
if (dest != null) {
start_import (selected, dest);
}
} catch (Error e) {
warning ("Folder selection failed: %s", e.message);
}
});
}
private void start_import (ImageInfo[] files, GLib.File dest_dir) {
import_button.sensitive = false;
disconnect_button.sensitive = false;
progress_box.visible = true;
progress_bar.fraction = 0.0;
int total = files.length;
progress_label.label = "Importing 0 of %d…".printf( total);
// Use a dedicated thread with its own CameraClient instance.
// Each thread gets a fresh Soup.Session so there's no sharing.
new Thread<void*>( "import", () => {
int completed = 0;
int failed = 0;
var import_camera = new CameraClient ();
for (int f = 0; f < total; f++) {
var file_info = files[f];
try {
var data = import_camera.download_original (file_info.full_path);
var dest_path = dest_dir.get_child (file_info.filename);
dest_path.replace_contents (
data.get_data (), null, false,
GLib.FileCreateFlags.NONE, null, null
);
completed++;
} catch (Error e) {
failed++;
warning ("Failed to import %s: %s", file_info.filename, e.message);
}
// Snapshot progress locals so the idle callback does not
// race with the worker thread updating completed/failed.
int snap_completed = completed;
int snap_failed = failed;
var snap_info = file_info;
Idle.add (() => {
progress_bar.fraction = (double)(snap_completed + snap_failed) / total;
progress_label.label = "Importing %d of %d (%s)".printf(
snap_completed, total, snap_info.filename);
return false;
});
}
int final_completed = completed;
int final_failed = failed;
Idle.add (() => {
if (final_failed == 0) {
progress_label.label = "Successfully imported %d of %d images!".printf( final_completed, total);
progress_label.add_css_class ("success");
} else {
progress_label.label = "Imported %d of %d (%d failed)".printf( final_completed, total, final_failed);
progress_label.add_css_class ("error");
}
progress_bar.fraction = 1.0;
Timeout.add (3000, () => {
progress_box.visible = false;
progress_bar.fraction = 0.0;
progress_label.remove_css_class ("success");
progress_label.remove_css_class ("error");
import_button.sensitive = true;
disconnect_button.sensitive = true;
for (int i = 0; i < image_widgets.length; i++) {
image_widgets[i].selected = false;
}
update_import_button_state ();
return false;
});
return false;
});
return null;
});
}
public void refresh () {
load_images ();
}
}
public class OlympusApp : Adw.Application {
public OlympusApp () {
Object (
application_id: "com.olympuswifi.viewer",
flags: ApplicationFlags.DEFAULT_FLAGS
);
}
protected override void activate () {
load_css ();
var window = new MainWindow (this);
window.present ();
}
private void load_css () {
var display = Gdk.Display.get_default ();
if (display == null) return;
var css_provider = new Gtk.CssProvider ();
css_provider.load_from_string (CSS);
Gtk.StyleContext.add_provider_for_display (
display,
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
);
}
private const string CSS = """
.title-label { font-weight: bold; }
.selected-image {
background-color: alpha(@accent_bg_color, 0.2);
border: 2px solid @accent_bg_color;
border-radius: 8px;
}
.image-item {
border-radius: 8px;
border: 2px solid transparent;
transition: all 200ms ease;
}
.image-item:hover {
background-color: alpha(@accent_bg_color, 0.08);
}
.error { color: @error_bg_color; font-weight: bold; }
.success { color: @success_bg_color; font-weight: bold; }
button.circular {
border-radius: 30px;
font-size: 1.2em;
font-weight: bold;
}
""";
}
public class MainWindow : Adw.ApplicationWindow {
private Gtk.Stack stack;
private ConnectPage connect_page;
private GalleryPage? gallery_page;
public MainWindow (Adw.Application app) {
Object (
application: app,
title: "Olympus Wi-Fi Camera Viewer",
default_width: 900,
default_height: 700
);
}
construct {
stack = new Gtk.Stack ();
stack.transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT;
connect_page = new ConnectPage ();
connect_page.connection_succeeded.connect (on_connected);
stack.add_named (connect_page, "connect");
set_content (stack);
stack.visible_child = connect_page;
}
private void on_connected () {
if (gallery_page == null) {
gallery_page = new GalleryPage ();
gallery_page.disconnect_requested.connect (on_disconnect);
stack.add_named (gallery_page, "gallery");
} else {
gallery_page.refresh ();
}
stack.visible_child = gallery_page;
}
private void on_disconnect () {
stack.visible_child = connect_page;
}
}
public static int main (string[] args) {
// Parse and strip debug flag before GTK processes args
var filtered = new string[args.length];
int out = 0;
for (int i = 0; i < args.length; i++) {
if (args[i] == "-d" || args[i] == "--debug") {
CameraClient.debug = true;
stdout.printf ("[debug] Enabled verbose HTTP logging\n");
} else {
filtered[out] = args[i];
out++;
}
}
// Trim to actual count
var clean_args = new string[out];
for (int i = 0; i < out; i++) {
clean_args[i] = filtered[i];
}
var app = new OlympusApp ();
return app.run (clean_args);
}