#!/usr/bin/env python3
"""Arko Bridge desktop for native X11. UI and input/clipboard workers are isolated."""
import datetime, json, os, pathlib, queue, shlex, signal, subprocess, sys, threading
import gi
from device_card import draw_device_card
gi.require_version('Gtk','3.0')
from gi.repository import Gtk, Gdk, Gio, GLib, Pango, GdkPixbuf

ROOT=pathlib.Path(__file__).resolve().parent
DATA=pathlib.Path(os.environ.get('XDG_DATA_HOME',str(pathlib.Path.home()/'.local/share')))/'arko-bridge-linux-prototype'
CSS=b'''
window, dialog, messagedialog {background:#111820;color:#edf1f6;} label {color:#edf1f6;}
.arko-dialog-content {background:#111820;color:#edf1f6;}
.arko-dialog-content label {color:#edf1f6;}
.header {font-size:25px;font-weight:bold;color:#bfd633;} .subtitle {color:#9caec0;}
.card {background:#1d2733;border-radius:10px;padding:18px;} .heading {font-size:17px;font-weight:bold;color:#bfd633;}
button {background:#283544;color:#edf1f6;border:0;border-radius:6px;padding:8px 14px;}
button.suggested-action label {color:#111820;} stackswitcher button:checked {background:#526424;}
button:hover {background:#35465b;} button:disabled {color:#718092;} button.suggested-action {background:#bfd633;color:#111820;}
entry,combobox,spinbutton,textview text,treeview {background:#19232d;color:#edf1f6;}
notebook > stack, notebook viewport, notebook list, notebook list row {background:#111820;color:#edf1f6;}
notebook header {background:#18222c;} notebook tab:checked {background:#35465b;}
.compact button {padding:4px 10px;} .compact .header {font-size:21px;}
.transfer {background:#233425;border:1px solid #789c40;border-radius:6px;padding:8px;}
.transfer label {color:#edf1f6;} progressbar trough {background:#34404b;min-height:8px;}
scale highlight,progressbar progress {background:#bfd633;} scale slider {background:#bfd633;min-width:17px;min-height:17px;}
'''

def box(vertical=True,spacing=12):return Gtk.Box(orientation=Gtk.Orientation.VERTICAL if vertical else Gtk.Orientation.HORIZONTAL,spacing=spacing)
def label(text,cls=None):
    w=Gtk.Label(label=text,xalign=0);w.set_line_wrap(True);w.set_max_width_chars(65)
    if cls:w.get_style_context().add_class(cls)
    return w
def button(text,callback,primary=False):
    w=Gtk.Button(label=text);w.connect('clicked',lambda _:callback())
    if primary:w.get_style_context().add_class('suggested-action')
    return w
def row(*widgets):
    b=box(False)
    for w in widgets:b.pack_start(w,False,False,0)
    return b
def flow(*widgets):
    b=Gtk.FlowBox();b.set_selection_mode(Gtk.SelectionMode.NONE);b.set_column_spacing(6);b.set_row_spacing(4);b.set_max_children_per_line(len(widgets));b.set_min_children_per_line(1)
    for w in widgets:b.add(w)
    return b
def scroll(child):
    w=Gtk.ScrolledWindow();w.set_overlay_scrolling(False);w.set_policy(Gtk.PolicyType.AUTOMATIC,Gtk.PolicyType.AUTOMATIC);w.add(child);return w

def size(n):
    n=float(n or 0)
    for unit in ('o','Ko','Mo','Go','To'):
        if n<1000:return f'{n:.1f} {unit}'
        n/=1000
    return f'{n:.1f} Po'

class Bridge(Gtk.Application):
    def __init__(self):
        super().__init__(application_id='com.arkointeractive.Bridge.Linux')
        self.transfer_finished=[];self.transfer_serial=0;self.sharing_grab=None;self.sharing_poll=0;self.session_state='idle';self.capture_setting='crossing_key';self.crossing_capture=False;self.crossing_timer=0;self.history_grab=None;self.history_capture=False;self.history_timer=0;self.history_poll=0;self.history_consumed=0
        self.proc=None;self.closing=False;self.updating=False;self.widgets={};self.peers={};self.config={};self.local={};self.selected='';self.license={};self.power_cookie=0;self.power_flags=0;self.screen_profiles=[];self.refreshing_peers=False;self.group_layout=[];self.group_running=False;self.group_remote=False;self.pending_confirm=set();self.outgoing=queue.Queue(128);self.logs=[]
    def do_activate(self):
        if hasattr(self,'window'):self.window.present();return
        css=Gtk.CssProvider();css.load_from_data(CSS);Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(),css,Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        self.window=Gtk.ApplicationWindow(application=self,title='Arko Bridge — Linux');self.window.set_default_size(940,650);self.window.set_size_request(720,500)
        outer=box(spacing=6);outer.set_border_width(12);self.window.add(outer)
        self.window.connect("size-allocate",lambda w,a:self.adapt_window(a))
        brand=box(False);titles=box(spacing=4);titles.add(label('ARKO BRIDGE','header'));titles.add(label('ARKO INTERACTIVE · Mac ↔ Windows ↔ Linux','subtitle'))
        icon=ROOT/'arko-bridge.png'
        if icon.exists():
            self.window.set_icon_from_file(str(icon));brand.pack_start(Gtk.Image.new_from_pixbuf(GdkPixbuf.Pixbuf.new_from_file_at_scale(str(icon),40,40,True)),False,False,0)
        brand.pack_start(titles,True,True,0);outer.pack_start(brand,False,False,0)
        self.stack=Gtk.Stack();self.stack.set_hhomogeneous(False);self.stack.set_vhomogeneous(False);self.stack.set_transition_type(Gtk.StackTransitionType.NONE)
        nav=Gtk.StackSwitcher(stack=self.stack);nav.set_hexpand(True)
        self.update_button=button('Rechercher une mise à jour',self.updates)
        navigation=box(False);navigation.pack_start(nav,True,True,0);navigation.pack_end(self.update_button,False,False,0);outer.pack_start(navigation,False,False,0)
        self.status=label('Démarrage du moteur…');outer.pack_start(self.status,False,False,0)
        self.typing_banner=label('Frappe clavier retenue','heading');self.typing_banner.set_no_show_all(True);outer.pack_start(self.typing_banner,False,False,0)
        self.stack.add_titled(self.devices_page(),'devices','Appareils')
        self.stack.add_titled(self.settings_page(),'settings','Réglages')
        self.stack.add_titled(self.diagnostic_page(),'diagnostic','Diagnostic')
        self.stack.connect('notify::visible-child',lambda *_:(self.stop_history_capture(),self.app_rules.stop() if hasattr(self,"app_rules") else None))
        outer.pack_start(self.stack,True,True,0)
        self.transfer_panel=box(spacing=4);self.transfer_panel.get_style_context().add_class('transfer');self.transfer_panel.set_no_show_all(True)
        self.transfer_text=label('');self.transfer_text.set_line_wrap(False);self.transfer_text.set_ellipsize(Pango.EllipsizeMode.END);self.transfer_text.set_max_width_chars(1)
        self.transfer=Gtk.ProgressBar();self.transfer_panel.pack_start(self.transfer_text,False,False,0);self.transfer_panel.pack_start(self.transfer,False,False,0);outer.pack_start(self.transfer_panel,False,False,0)
        self.license_label=label('Licence : vérification…','subtitle')
        self.license_label.set_ellipsize(Pango.EllipsizeMode.END);self.license_label.set_max_width_chars(35)
        footer=box(False);footer.pack_start(self.license_label,True,True,0);footer.pack_end(button('Quitter complètement',self.quit),False,False,0);footer.pack_end(button('Licence…',self.license_dialog),False,False,0);outer.pack_start(footer,False,False,0)
        self.window.connect('delete-event',self.hide_window)
        GLib.unix_signal_add(GLib.PRIORITY_DEFAULT,signal.SIGTERM,lambda:(self.quit() or False))
        self.window.show_all()
        if os.geteuid()==0:self.status.set_text('Lance Arko depuis ta session utilisateur, sans sudo.');return
        self.window.connect('key-press-event',self.history_key_press)
        self.window.connect('key-release-event',self.history_key_release)
        self.window.connect('focus-out-event',lambda *_:self.keyboard_editor.stop_capture() or self.stop_history_capture())
        Gdk.Keymap.get_for_display(Gdk.Display.get_default()).connect('keys-changed',self.history_layout_changed)
        self.start_engine()
    def hide_window(self,*_):
        self.window.hide();return True
    def add_check(self,parent,key,title,default=False):
        w=Gtk.CheckButton(label=title);w.set_active(default);w.connect('toggled',lambda b:self.change(key,b.get_active()));self.widgets[key]=w;parent.pack_start(w,False,False,0);return w
    def change(self,key,value):
        if not self.updating:self.send('set_config',**{key:value})
    def adapt_window(self,allocation):
        compact=allocation.width<1100 or allocation.height<800
        if getattr(self,'compact',None)==compact:return
        self.compact=compact
        context=self.window.get_style_context()
        (context.add_class if compact else context.remove_class)('compact')
        if hasattr(self,'peer_scroll'):self.resize_peer_lists()
    def resize_peer_lists(self):
        for sc,model in ((self.peer_scroll,self.model),(self.nearby_scroll,self.nearby_model)):
            # Keep a readable directory without letting a large fleet consume the canvas.
            height=36+max(1,min(len(model),2 if getattr(self,'compact',True) else 5))*32
            sc.set_max_content_height(-1);sc.set_min_content_height(height);sc.set_max_content_height(height)
    def devices_page(self):
        page=box(spacing=6)
        self.start_button=button('Démarrer le partage',self.start_share,True)
        page.pack_start(flow(self.start_button,button('Arrêter le partage',lambda:self.send('stop')),button('Libérer les entrées',lambda:self.send('release_all'))),False,False,0)
        self.add_check(page,'auto_share','Partager automatiquement au lancement')
        b=box(spacing=6);body=scroll(b);body.set_policy(Gtk.PolicyType.NEVER,Gtk.PolicyType.AUTOMATIC);page.pack_start(body,True,True,0)
        self.peer_search=Gtk.SearchEntry();self.peer_search.set_placeholder_text('Rechercher un ordinateur par nom ou adresse IP');self.peer_search.connect('search-changed',lambda *_:self.refresh_peers())
        b.pack_start(self.peer_search,False,False,0)
        def peer_tree():
            model=Gtk.ListStore(str,str,str,str,str,str);tree=Gtk.TreeView(model=model);tree.set_tooltip_column(1)
            for i,title in enumerate(('Ordinateurs appairés','Système','Adresse IP','État')):
                renderer=Gtk.CellRendererText();renderer.set_property('ellipsize',Pango.EllipsizeMode.END);renderer.set_property('ypad',5)
                column=Gtk.TreeViewColumn(title,renderer,text=i+1,foreground=5);column.set_sizing(Gtk.TreeViewColumnSizing.FIXED);column.set_fixed_width((180,80,125,170)[i]);column.set_resizable(True);column.set_expand(i==0);tree.append_column(column)
            tree.get_selection().connect('changed',self.select_peer)
            sc=scroll(tree);sc.set_policy(Gtk.PolicyType.NEVER,Gtk.PolicyType.AUTOMATIC);sc.set_propagate_natural_height(True)
            return model,tree,sc
        self.model,self.tree,self.peer_scroll=peer_tree();b.pack_start(self.peer_scroll,False,False,0)
        self.tree.set_tooltip_text('Cliquer pour inclure ou retirer un ordinateur de la disposition')
        self.tree.connect('button-press-event',self.layout_peer_click)
        self.tree.connect('key-press-event',lambda w,e:e.keyval==Gdk.KEY_space)
        self.tree.connect('key-release-event',self.layout_peer_key)
        self.nearby_model,self.nearby_tree,self.nearby_scroll=peer_tree();self.nearby_tree.get_column(0).set_title('Ordinateurs non appairés')
        self.nearby_expander=Gtk.Expander(label='Appareils non appairés');self.nearby_expander.add(self.nearby_scroll);b.pack_start(self.nearby_expander,False,False,0)
        b.pack_start(flow(button('Appairer',lambda:self.peer_action('pair')),button('Dissocier',self.unpair),button('Actualiser',lambda:self.send('snapshot')),button('Envoyer des fichiers…',self.send_files)),False,False,0)
        options=box(spacing=6);self.device_options=Gtk.Expander(label='Options et profils');self.device_options.add(options)
        self.add_check(options,'allow_remote_control','Autoriser les sessions lancées depuis les autres ordinateurs',True)
        self.edge=Gtk.ComboBoxText()
        for key,title in (('left','Gauche'),('right','Droite'),('top','Haut'),('bottom','Bas')):self.edge.append(key,title)
        self.edge.set_active_id('left');self.edge.connect('changed',lambda w:self.change('edge',w.get_active_id()))
        self.direction=Gtk.ComboBoxText();self.direction.append('local','Cet ordinateur contrôle l’autre');self.direction.append('remote','L’autre ordinateur contrôle celui-ci');self.direction.set_active_id('local')
        options.pack_start(flow(label('Disposition des écrans'),self.edge,self.direction),False,False,0)
        self.profile_combo=Gtk.ComboBoxText();self.profile_combo.append('','Choisir une disposition…');self.profile_combo.set_active(0);self.profile_combo.connect('changed',self.apply_screen_profile)
        self.profile_name=Gtk.Entry();self.profile_name.set_placeholder_text('Nom de la disposition');self.profile_name.set_width_chars(18)
        options.pack_start(flow(self.profile_combo,self.profile_name,button('Enregistrer',self.save_screen_profile),button('Supprimer',self.delete_screen_profile)),False,False,0)
        b.pack_start(label('Disposition des écrans','heading'),False,False,4)
        self.add_layout_button=button('Ajouter l’ordinateur',lambda:self.change_layout_member(True))
        self.remove_layout_button=button('Retirer l’ordinateur',lambda:self.change_layout_member(False))
        self.edit_layout_button=button('Disposer les écrans…',self.edit_layout)
        b.pack_start(flow(self.add_layout_button,self.remove_layout_button,self.edit_layout_button),False,False,0)
        b.pack_start(self.device_options,False,False,0)
        self.layout=Gtk.DrawingArea();self.layout.set_size_request(-1,160);self.layout.connect('draw',self.draw_layout);b.pack_start(self.layout,True,True,0)
        self.resize_peer_lists()
        return page
    def edit_layout(self):
        if not self.local.get('id') or self.group_remote:return
        from desktop_layout import LayoutDialog
        dialog=LayoutDialog(self.window,self.local,self.peers,self.editable_layout(),lambda layout:self.send('group_config',group_layout=layout));dialog.show_all()
    def editable_layout(self):
        if self.group_layout:return self.group_layout
        local=dict(id=self.local['id'],name=self.local.get('name',''),x=0,y=0,width=self.config.get('screen_local_scale') or 1,height=self.config.get('screen_local_scale') or 1)
        screens=[local];peer=self.peers.get(self.selected)
        if peer and peer.get('paired'):
            scale=self.config.get('screen_remote_scale') or 1
            offset=(.5+self.config.get('screen_offset',0))*local['width']-.5*scale
            edge=self.edge.get_active_id();x,y={'left':(-scale,offset),'right':(local['width'],offset),'top':(offset,-scale),'bottom':(offset,local['height'])}.get(edge,(local['width'],offset))
            screens.append(dict(id=peer['id'],name=peer.get('name',''),x=x,y=y,width=scale,height=scale))
        return screens
    def change_layout_member(self,add):
        if self.group_remote:return
        peer=self.peers.get(self.selected,{})
        if peer.get('paired'):self.send('layout_add' if add else 'layout_remove',peer_id=self.selected)
    def toggle_layout_peer(self,path):
        if self.group_remote or path is None:return
        it=self.model.get_iter(path);peer_id=self.model[it][0]
        if self.peers.get(peer_id,{}).get('paired'):
            self.selected=peer_id
            self.tree.get_selection().select_path(path)
            self.send('layout_toggle',peer_id=peer_id)
    def layout_peer_click(self,tree,event):
        if event.button!=1:return False
        # GTK emits an extra double/triple-click notification after BUTTON_PRESS.
        # Consume those without toggling a second time for the same physical click.
        if event.type!=Gdk.EventType.BUTTON_PRESS:return True
        hit=tree.get_path_at_pos(int(event.x),int(event.y))
        if hit:
            tree.grab_focus();tree.set_cursor(hit[0]);self.toggle_layout_peer(hit[0])
        return True
    def layout_peer_key(self,tree,event):
        if event.keyval!=Gdk.KEY_space:return False
        self.toggle_layout_peer(tree.get_cursor()[0]);return True
    def refresh_layout_actions(self):
        peer=self.peers.get(self.selected,{})
        included=any(s['id']==self.selected for s in self.group_layout)
        self.add_layout_button.set_sensitive(not self.group_remote and bool(peer.get('paired')) and not included)
        self.remove_layout_button.set_sensitive(not self.group_remote and bool(peer.get('paired')) and (included or not self.group_layout))
        self.edit_layout_button.set_sensitive(not self.group_remote)
        self.direction.set_sensitive(not self.group_layout)
        self.direction.set_tooltip_text('Clavier et souris : '+self.local.get('name','cet ordinateur') if self.group_layout else 'Choisir l’ordinateur qui fournit le clavier et la souris')
        self.start_button.set_sensitive((len(self.group_layout)>1 and not self.group_remote) if self.group_layout else bool(peer.get('paired') and peer.get('online')))
    def select_peer(self,selection):
        if self.refreshing_peers:return
        model,it=selection.get_selected()
        if it is None:return
        self.selected=model[it][0];self.refreshing_peers=True
        other=self.nearby_tree if model is self.model else self.tree
        other.get_selection().unselect_all();self.refreshing_peers=False;self.layout.queue_draw();self.refresh_layout_actions()
    def save_screen_profile(self):
        name=self.profile_name.get_text().strip()
        if not name:self.message('Disposition','Saisis un nom pour cette disposition.');return
        if any(p['name'].casefold()==name.casefold() for p in self.screen_profiles):
            if not self.confirm('Remplacer cette disposition ?',name):return
        self.send('screen_profile_save',profile_name=name,peer_id=self.selected,controller=self.direction.get_active_id())
    def apply_screen_profile(self,w):
        if not self.updating and w.get_active_id():self.send('screen_profile_apply',profile_name=w.get_active_id())
    def delete_screen_profile(self):
        name=self.profile_combo.get_active_id()
        if name and self.confirm('Supprimer cette disposition ?',name):self.send('screen_profile_delete',profile_name=name)
    def refresh_screen_profiles(self,profiles):
        if profiles==self.screen_profiles:return
        self.screen_profiles=profiles;self.updating=True;selected=self.profile_combo.get_active_id();self.profile_combo.remove_all();self.profile_combo.append('','Choisir une disposition…')
        for p in profiles:self.profile_combo.append(p['name'],p['name'])
        self.profile_combo.set_active_id(selected if any(p['name']==selected for p in profiles) else '');self.updating=False
    def draw_layout(self,w,c):
        if self.group_layout:
            screens=self.group_layout
            left=min(s.get('x',0) for s in screens);top=min(s.get('y',0) for s in screens)
            right=max(s.get('x',0)+(s.get('width') or 1) for s in screens);bottom=max(s.get('y',0)+(s.get('height') or 1) for s in screens)
            scale=min((w.get_allocated_width()-24)/max(1,right-left),(w.get_allocated_height()-24)/max(1,bottom-top))
            for s in screens:
                x=(w.get_allocated_width()-(right-left)*scale)/2+(s.get('x',0)-left)*scale;y=(w.get_allocated_height()-(bottom-top)*scale)/2+(s.get('y',0)-top)*scale;ww=(s.get('width') or 1)*scale;hh=(s.get('height') or 1)*scale
                c.set_source_rgb(.20,.29,.12) if s['id']==self.local.get('id') else c.set_source_rgb(.12,.17,.22)
                c.rectangle(x,y,ww,hh);c.fill_preserve();c.set_source_rgb(.75,.84,.2);c.set_line_width(2);c.stroke()
                device=self.local if s['id']==self.local.get('id') else self.peers.get(s['id'],s)
                draw_device_card(c,device,x,y,ww,hh,s.get("width") or 1,s.get("height") or 1)
            return False
        width=w.get_allocated_width();height=w.get_allocated_height();vertical=self.edge.get_active_id() in ('top','bottom')
        devices=[self.local]
        peer=self.peers.get(self.selected)
        if peer:devices.append(peer)
        if self.edge.get_active_id() in ('left','top'):devices.reverse()
        count=len(devices)
        for i,device in enumerate(devices):
            x=12+i*(width/count) if not vertical else 12;y=12 if not vertical else 6+i*height/count
            bw=width/count-24 if not vertical else width-24;bh=height-24 if not vertical else height/count-10
            c.set_source_rgb(.16,.23,.15) if device is self.local else c.set_source_rgb(.12,.17,.22)
            c.rectangle(x,y,bw,bh);c.fill_preserve();c.set_source_rgb(.75,.84,.2);c.set_line_width(2);c.stroke()
            scale=self.config.get("screen_local_scale" if device is self.local else "screen_remote_scale") or 1
            draw_device_card(c,device,x,y,bw,bh,scale,scale)
        return False
    def settings_page(self):
        tabs=Gtk.Notebook()
        tabs.connect('switch-page',lambda *_:(self.stop_history_capture(),self.app_rules.stop() if hasattr(self,"app_rules") else None))
        for title,maker in [('Général',self.general),('Presse-papiers',self.clipboard_settings),('Clavier',self.keyboard),('Applications',self.applications)]:
            content=box();content.set_border_width(18);maker(content);tabs.append_page(scroll(content),Gtk.Label(label=title))
        return tabs
    def heading(self,b,text):b.pack_start(label(text,'heading'),False,False,4)
    def general(self,b):
        self.heading(b,'Démarrage et partage');self.add_check(b,'start_at_login','Démarrer avec la session')
        self.add_check(b,'prevent_mac_sleep','Empêcher la mise en veille automatique de cet ordinateur')
        self.add_check(b,'prevent_display_sleep','Garder l’écran allumé')
        b.pack_start(label('Actif tant que Bridge fonctionne. Le verrouillage manuel et les règles de sécurité restent actifs.','subtitle'),False,False,0)
        self.heading(b,'Souris et roulette')
        for key,title,lo,hi in [('mouse_scale','Souris envoyée',.5,2),('scroll_scale','Roulette reçue',.25,5)]:
            scale=Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL,lo,hi,.05);scale.set_value(1);scale.set_digits(2);scale.set_hexpand(True)
            scale.connect('value-changed',lambda w,k=key:self.debounce(k,round(w.get_value(),2)));self.widgets[key]=scale
            line=row(label(title));line.pack_start(scale,True,True,0);b.pack_start(line,False,False,0)
        self.heading(b,'Raccourcis')
        b.pack_start(label('Arrêt d’urgence pendant le contrôle distant : Ctrl + Alt + Échap.','subtitle'),False,False,0)
        self.add_check(b,'sharing_hotkey_enabled','Démarrer / arrêter avec une touche')
        self.sharing_button=button('F14',lambda:self.begin_crossing_capture('sharing_hotkey'))
        self.sharing_hint=label('Clique puis appuie sur la touche souhaitée.','subtitle')
        b.pack_start(row(label('Démarrer / arrêter'),self.sharing_button),False,False,0);b.pack_start(self.sharing_hint,False,False,0)
        self.crossing_button=button('Tab',self.begin_crossing_capture)
        b.pack_start(row(label('Passage en plein écran'),self.crossing_button),False,False,0)
        b.pack_start(label('Maintiens cette touche en allant vers le bord. Clique sur le bouton pour choisir une touche ; Échap annule.','subtitle'),False,False,0)
    def debounce(self,key,value):
        if self.updating:return
        pending=getattr(self,'pending_settings',{});self.pending_settings=pending
        if key in pending:GLib.source_remove(pending[key])
        def flush():pending.pop(key,None);self.change(key,value);return False
        pending[key]=GLib.timeout_add(220,flush)
    def choice(self,b,key,title,choices):
        combo=Gtk.ComboBoxText()
        for value,text in choices:combo.append(str(value),text)
        combo.connect('changed',lambda w:self.change(key,int(w.get_active_id())) if w.get_active_id() is not None else None)
        self.widgets[key]=combo;b.pack_start(row(label(title),combo),False,False,0)
    def clipboard_settings(self,b):
        self.heading(b,'Partage du presse-papiers');self.add_check(b,'clipboard_text','Activer le presse-papiers partagé',True)
        self.add_check(b,'clipboard_rename_prompt','Demander le nom avant de coller un fichier généré')
        b.pack_start(label('Ctrl+V / Maj+Inser dans un gestionnaire de fichiers X11 accessible. Les vrais fichiers gardent leur nom ; le menu Coller reste inchangé.','subtitle'),False,False,0)
        self.choice(b,'clipboard_confirm_mb','Confirmer avant l’envoi',[(x,'Sans confirmation' if x==0 else f'Au-delà de {x} Mo') for x in (0,10,50,100,500,1000,5000,10000)])
        self.heading(b,'Transferts et espace disque')
        self.choice(b,'transfer_limit_mib','Débit',[(x,'Auto · 10 Gbit/s max' if x==0 else ('10 Gbit/s maximum' if x==1192 else f'{x} Mio/s maximum')) for x in (0,16,32,64,112,256,512,1024,1192)])
        self.choice(b,'clipboard_cache_gib','Cache',[(x,f'{x} Gio') for x in (5,10,20,50,100,250,500,1024)])
        b.pack_start(row(button('Ouvrir les fichiers reçus',self.open_cache),button('Actualiser l’espace',lambda:self.send('clipboard_cache_info'))),False,False,0)
        self.cache_label=label('Les fichiers reçus restent dans le dossier Clipboard Transfers.','subtitle');b.pack_start(self.cache_label,False,False,0)
        self.heading(b,'Historique local chiffré');self.add_check(b,'clipboard_history','Conserver un historique chiffré')
        self.choice(b,'clipboard_history_days','Conservation',[(x,f'{x} jours') for x in (1,3,7,14,30,90,365)])
        from history_shortcut import DEFAULT,label as shortcut_label
        self.history_button=button(shortcut_label(DEFAULT),self.begin_history_capture)
        self.history_hint=label('Clique sur le raccourci puis appuie sur la combinaison souhaitée. Échap annule.','subtitle')
        b.pack_start(row(label('Raccourci'),self.history_button),False,False,0);b.pack_start(self.history_hint,False,False,0)
        b.pack_start(row(button('Voir l’historique' ,lambda:self.send('clipboard_history_list')),button('Effacer l’historique',self.clear_history)),False,False,0)
    def keyboard(self,b):
        self.heading(b,'Clavier de cet ordinateur')
        from keyboard_view import KeyboardView
        self.keyboard_view=KeyboardView();b.pack_start(self.keyboard_view,False,False,0)
        b.pack_start(label('La disposition est celle de ce bureau Linux. Choisis la langue du clavier dans les réglages système.','subtitle'),False,False,0)
        b.pack_start(button('Ouvrir les réglages du clavier Linux',self.open_keyboard),False,False,0)
        self.key_preview=label('Appuie sur une touche dans la zone de test.');b.pack_start(self.key_preview,False,False,0)
        test=Gtk.Entry();test.set_placeholder_text('Tape ici pour vérifier le résultat sur cet ordinateur.');test.connect('key-press-event',self.preview_key);b.pack_start(test,False,False,0)
        from keyboard_editor import KeyboardEditor
        self.keyboard_editor=KeyboardEditor(self.keyboard_view,self.send);b.pack_start(self.keyboard_editor,False,False,0)
    def preview_key(self,_w,event):self.key_preview.set_text('Touche : '+(Gdk.keyval_name(event.keyval) or str(event.keyval)));return False
    def applications(self,b):
        from application_rules import ApplicationRules
        self.app_rules=ApplicationRules(self.window,self.send);b.pack_start(self.app_rules.view,False,False,0)

    def diagnostic_page(self):
        b=box();b.pack_start(row(button('Copier les logs',self.copy_logs),button('Effacer',self.clear_logs),button('Exporter…',self.export_logs)),False,False,0)
        self.log_view=Gtk.TextView(editable=False,monospace=True);self.log_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR);b.pack_start(scroll(self.log_view),True,True,0);return b
    def start_engine(self):
        DATA.mkdir(parents=True,exist_ok=True);DATA.chmod(0o700)
        args=[str(ROOT/'arko-sync-node-linux'),'--name',os.uname().nodename,'--data-dir',str(DATA),'--mouse-agent',str(ROOT/'arko-input-x11.py'),'--clipboard-agent',str(ROOT/'arko-clipboard-linux.py'),'--parent-pid',str(os.getpid())]
        try:self.proc=subprocess.Popen(args,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,bufsize=1)
        except OSError as e:self.status.set_text(str(e));return
        threading.Thread(target=self.write_commands,daemon=True).start()
        threading.Thread(target=self.read_events,daemon=True).start()
        threading.Thread(target=self.read_errors,daemon=True).start()
    def write_commands(self):
        try:
            while True:
                value=self.outgoing.get()
                if value is None:return
                self.proc.stdin.write(json.dumps(value,separators=(',',':'))+'\n');self.proc.stdin.flush()
        except (OSError,ValueError):pass
    def send(self,command,**fields):
        if not self.proc or self.proc.poll() is not None:return
        try:self.outgoing.put_nowait(dict(command=command,**fields))
        except queue.Full:self.log('ERROR','Le moteur ne répond plus. Arrête puis relance Bridge.')
    def read_events(self):
        for line in self.proc.stdout:
            try:event=json.loads(line)
            except ValueError:continue
            GLib.idle_add(self.event,event)
        GLib.idle_add(self.engine_stopped)
    def read_errors(self):
        for line in self.proc.stderr:GLib.idle_add(self.log,'ERROR',line.strip())
    def engine_stopped(self):
        if not self.closing:self.status.set_text('Moteur arrêté — consulte le diagnostic.');self.update_power(False,False)
        return False
    def event(self,e):
        if self.closing:return False
        kind=e.get('event')
        if kind in ('engine_ready','snapshot'):
            self.local=e.get('local',self.local)
            if kind=='engine_ready':self.status.set_text('Prêt — sélectionne un ordinateur et démarre le partage.');self.send('snapshot')
            if 'peers' in e:self.peers={p['id']:p for p in e['peers']};self.refresh_peers()
            if 'group' in e:self.group_layout=e['group'].get('layout') or [];self.group_running=e['group'].get('running',False);self.group_remote=bool(e['group'].get('controller'));self.refresh_peers()
            if 'session' in e:self.session_state=e['session'].get('state','idle')
            if 'config' in e:self.apply_config(e['config'])
            if 'screen_profiles' in e:self.refresh_screen_profiles(e['screen_profiles'])
            if 'license' in e:self.apply_license(e['license'])
            if kind=='engine_ready':self.update_ready()
        elif kind=='peer':
            p=e.get('peer',{});self.peers[p['id']]=p;self.refresh_peers()
        elif kind=='screen_profile_applied':
            self.selected=e.get('peer_id','');self.direction.set_active_id(e.get('controller','local'));self.refresh_peers();self.status.set_text('Disposition «'+e.get('name','')+'» appliquée')
        elif kind=='keyboard_device' and e.get('device',{}).get('id')==self.local.get('id'):
            if e.get('saved'):self.keyboard_editor.saved(e['device']['profile'])
            else:self.keyboard_editor.update(e['device']['profile'])
        elif kind=='keyboard_library' and e.get('peer_id')==self.local.get('id'):
            self.keyboard_editor.receive_library(e.get('entries',[]))
        elif kind=='pair_prompt':self.pair_prompt(e)
        elif kind in ('paired','unpaired'):self.send('snapshot')
        elif kind=='license':self.apply_license(e.get('license',{}))
        elif kind=='license_operation':self.message('Licence',e.get('message','Opération terminée'))
        elif kind=='session':
            self.session_state=e.get('state','idle')
            states={'idle':'Partage arrêté','connected_local':'Connecté — curseur sur cet ordinateur','remote_active':'Contrôle distant actif','connecting':'Connexion en cours…'}
            self.status.set_text(states.get(e.get('state'),e.get('state',''))+' '+e.get('name',''))
        elif kind=='typing_guard':
            self.typing_banner.set_text(e.get('message','Frappe clavier retenue'));self.typing_banner.set_visible(e.get('active',False))
        elif kind in ('log','error','fatal'):
            self.log(e.get('level',kind).upper(),e.get('message',''))
            if kind in ('error','fatal'):
                self.status.set_text(e.get('message','Erreur'))
                if self.keyboard_editor.pending:
                    self.keyboard_editor.update(self.config.get('device_keyboard',{}),True)
                    self.keyboard_editor.hint.set_text(e.get('message','Enregistrement refusé'))
        elif kind=='clipboard_transfer':self.show_transfer(e)
        elif kind=='clipboard_cache':self.cache_label.set_text('Fichiers reçus : '+size(e.get('bytes_used'))+' utilisés.')
        elif kind=='clipboard_history':self.show_history(e.get('entries',[]))
        return False
    def refresh_peers(self):
        selected=self.selected;self.refreshing_peers=True
        query=self.peer_search.get_text().strip().casefold()
        self.model.clear();self.nearby_model.clear()
        nearby=0
        for p in sorted(self.peers.values(),key=lambda x:(not x.get('paired'),not x.get('online'),x.get('name','').casefold(),x['id'])):
            paired=p.get('paired');online=p.get('online')
            if not paired and not online:continue
            if query and query not in p.get('name','').casefold() and query not in p.get('address','').casefold():continue
            model,tree=(self.model,self.tree) if paired else (self.nearby_model,self.nearby_tree)
            if not paired:nearby+=1
            status=('En ligne' if online else 'Hors ligne')+(' · dans la disposition' if any(s['id']==p['id'] for s in self.group_layout) else (' · appairé' if paired else ' · non appairé'))
            color=('#86d99b' if online else '#a9b2c0') if paired else '#88bfff'
            mark=('☑ ' if any(s['id']==p['id'] for s in self.group_layout) else '☐ ') if paired else ''
            it=model.append([p['id'],mark+p.get('name',''),{'darwin':'Mac','windows':'Windows','linux':'Linux'}.get(p.get('platform'),'?'),p.get('address',''),status,color])
            if p['id']==selected:tree.get_selection().select_iter(it)
        self.nearby_expander.set_label('Appareils non appairés (%d)'%nearby);self.refreshing_peers=False;self.resize_peer_lists();self.layout.queue_draw();self.refresh_layout_actions()
    def apply_config(self,c):
        self.config=c;self.updating=True
        old=self.keyboard_editor.original
        self.keyboard_editor.peer=self.local.get('id','')
        self.keyboard_editor.update(c.get('device_keyboard',{}))
        if old!=self.keyboard_editor.original:self.keyboard_editor.request_library()
        self.publish_keyboard_layout()
        self.app_rules.update(c.get("app_key_rules") or [])
        try:
            for key,w in self.widgets.items():
                if key not in c:continue
                if isinstance(w,Gtk.CheckButton):w.set_active(bool(c[key]))
                elif isinstance(w,Gtk.Scale):
                    if not w.has_focus():w.set_value(float(c[key]))
                elif isinstance(w,Gtk.ComboBoxText):w.set_active_id(str(c[key]))
            self.edge.set_active_id(c.get('edge','left'))
            self.update_power(c.get('prevent_mac_sleep',False),c.get('prevent_display_sleep',False))
            self.update_autostart(c.get('start_at_login',False))
            self.update_history_shortcut();self.update_sharing_shortcut()
            if not self.crossing_capture:
                from history_shortcut import label as shortcut_label
                self.crossing_button.set_label(shortcut_label(c.get('crossing_key','HID:2B'),self.keyboard_view.codes))
        finally:self.updating=False
    def toggle_sharing(self):
        if self.crossing_capture or self.history_capture:return False
        if self.session_state!='idle' or self.group_running:self.send('stop')
        else:self.start_share()
        return False
    def update_sharing_shortcut(self):
        from history_shortcut import GlobalShortcut,label as shortcut_label
        value=self.config.get('sharing_hotkey','F14')
        try:
            if not self.sharing_grab:self.sharing_grab=GlobalShortcut(lambda:GLib.idle_add(self.toggle_sharing))
            if not self.sharing_poll:self.sharing_poll=GLib.io_add_watch(self.sharing_grab.fileno(),GLib.IO_IN,lambda *_:self.sharing_grab.poll())
            if not self.crossing_capture:self.sharing_button.set_label(shortcut_label(value,self.sharing_grab.codes))
            self.sharing_grab.configure(value,self.config.get('sharing_hotkey_enabled',True) and not self.crossing_capture and not self.history_capture)
            self.sharing_hint.set_text('Clique puis appuie sur la touche souhaitée. Échap annule.')
        except (RuntimeError,ValueError,OSError,subprocess.SubprocessError) as error:self.sharing_hint.set_text(str(error))
    def publish_keyboard_layout(self):
        layout=self.keyboard_view.native_layout()
        if layout and layout!=(self.config.get('device_keyboard') or {}).get('layout'):
            if getattr(self,'pending_native_layout',None)!=layout:
                self.pending_native_layout=layout;self.send('keyboard_layout',profile={'layout':layout})
        else:self.pending_native_layout=None
    def history_layout_changed(self,*_):
        self.publish_keyboard_layout()
        if self.history_grab:
            self.history_grab.clear();self.update_history_shortcut()
        if self.sharing_grab:self.sharing_grab.clear();self.update_sharing_shortcut()
    def update_history_shortcut(self):
        from history_shortcut import GlobalShortcut,DEFAULT,label as shortcut_label
        value=self.config.get('clipboard_history_hotkey',DEFAULT)
        try:
            if not self.history_grab:self.history_grab=GlobalShortcut(lambda:GLib.idle_add(self.request_history))
            if not self.history_poll:self.history_poll=GLib.io_add_watch(self.history_grab.fileno(),GLib.IO_IN,lambda *_:self.history_grab.poll())
            if not self.history_capture:self.history_button.set_label(shortcut_label(value,self.history_grab.codes))
            self.history_grab.configure(value,self.config.get('clipboard_history',False) and not self.history_capture)
            if not self.history_capture:self.history_hint.set_text('Clique puis appuie sur la combinaison souhaitée. Échap annule.')
        except (RuntimeError,ValueError,OSError,subprocess.SubprocessError) as error:self.history_hint.set_text(str(error))
    def request_history(self):
        if not self.history_capture and self.config.get('clipboard_history',False):self.send('clipboard_history_list')
        return False
    def begin_crossing_capture(self,setting='crossing_key'):
        self.stop_history_capture();self.capture_setting=setting;self.crossing_capture=True;self.update_sharing_shortcut()
        widget=self.sharing_button if setting=='sharing_hotkey' else self.crossing_button
        widget.set_label('Appuie sur une touche…');widget.grab_focus()
        self.crossing_timer=GLib.timeout_add_seconds(15,self.stop_history_capture)
    def begin_history_capture(self):
        self.stop_history_capture();self.history_capture=True;self.update_history_shortcut();self.update_sharing_shortcut()
        self.history_button.set_label('Appuie sur le raccourci…');self.history_button.grab_focus()
        self.history_timer=GLib.timeout_add_seconds(15,self.stop_history_capture)
    def stop_history_capture(self):
        if self.crossing_capture:
            self.crossing_capture=False
            from history_shortcut import label as shortcut_label
            self.crossing_button.set_label(shortcut_label(self.config.get('crossing_key','HID:2B'),self.keyboard_view.codes))
            self.update_sharing_shortcut()
        if self.crossing_timer:GLib.source_remove(self.crossing_timer);self.crossing_timer=0
        was=self.history_capture;self.history_capture=False
        if self.history_timer:GLib.source_remove(self.history_timer);self.history_timer=0
        if was:self.update_history_shortcut();self.update_sharing_shortcut()
        return False
    def history_key_press(self,_window,event):
        if self.app_rules.capture(event):return True
        if self.crossing_capture:
            usage=self.keyboard_view.codes.get(event.hardware_keycode,0)
            if usage==41:self.stop_history_capture()
            elif 4<=usage<=115:
                value='F%d'%(usage-57) if 58<=usage<=69 else ('F%d'%(usage-91) if 104<=usage<=115 else 'HID:%02X'%usage)
                self.config[self.capture_setting]=value;self.change(self.capture_setting,value);self.stop_history_capture()
            self.history_consumed=event.hardware_keycode;return True
        if self.keyboard_view.get_mapped():
            self.keyboard_view.preview(event)
            if self.keyboard_editor.observe(event):self.history_consumed=event.hardware_keycode;return True
        if event.hardware_keycode==self.history_consumed:return True
        if not self.history_capture:return False
        from history_shortcut import record,MODS
        if event.keyval==Gdk.KEY_Escape and not any(event.state&flag for _,flag,_,_ in MODS):
            self.history_consumed=event.hardware_keycode;self.stop_history_capture();return True
        if not self.history_grab:return True
        value=record(event,self.history_grab.codes)
        if value:
            self.history_consumed=event.hardware_keycode
            self.config['clipboard_history_hotkey']=value
            self.change('clipboard_history_hotkey',value);self.stop_history_capture()
        return True
    def history_key_release(self,_window,event):
        if self.app_rules.capture(event):return True
        if self.keyboard_view.get_mapped():self.keyboard_view.preview(event)
        if self.history_consumed==event.hardware_keycode:self.history_consumed=0;return True
        return self.history_capture
    def update_power(self,system,display):
        flags=int(Gtk.ApplicationInhibitFlags.SUSPEND) if system or display else 0
        if display:flags|=int(Gtk.ApplicationInhibitFlags.IDLE)
        if flags==self.power_flags:return
        if self.power_cookie:self.uninhibit(self.power_cookie);self.power_cookie=0
        self.power_flags=0
        if flags:
            self.power_cookie=self.inhibit(self.window,Gtk.ApplicationInhibitFlags(flags),'Arko Bridge : accès depuis un autre ordinateur')
            if self.power_cookie:self.power_flags=flags
            else:self.log('WARNING','Le bureau n’a pas accepté la protection contre la veille.')
    def update_autostart(self,enabled):
        path=pathlib.Path.home()/'.config/autostart/arko-bridge.desktop'
        try:
            if enabled:
                path.parent.mkdir(parents=True,exist_ok=True)
                executable=str(ROOT/'arko-bridge').replace('\\','\\\\').replace('"','\\"').replace('`','\\`').replace('$','\\$').replace('%','%%')
                content='[Desktop Entry]\nType=Application\nName=Arko Bridge\nExec="'+executable+'"\nTerminal=false\nX-GNOME-Autostart-enabled=true\n'
                if not path.exists() or path.read_text()!=content:path.write_text(content);path.chmod(0o600)
            elif path.exists():path.unlink()
        except OSError as e:self.log('ERROR','Démarrage avec la session : '+str(e))
    def peer_action(self,command):
        if self.selected:self.send(command,peer_id=self.selected)
        else:self.message('Appareils','Sélectionne d’abord un ordinateur.')
    def unpair(self):
        if self.selected and self.confirm('Dissocier cet ordinateur ?','Un nouvel appairage sera nécessaire.'):self.peer_action('unpair')
    def start_share(self):
        if self.group_remote:return
        if self.group_layout:
            if len(self.group_layout)>1:self.send('group_start')
            return
        if not self.selected:return self.message('Appareils','Sélectionne d’abord un ordinateur.')
        self.send('start',peer_id=self.selected,edge=self.edge.get_active_id(),controller=self.direction.get_active_id())
    def message_dialog(self,title,text,question=False):
        d=Gtk.MessageDialog(transient_for=self.window,modal=True,message_type=Gtk.MessageType.QUESTION if question else Gtk.MessageType.INFO,buttons=Gtk.ButtonsType.YES_NO if question else Gtk.ButtonsType.OK,text=title)
        d.get_content_area().get_style_context().add_class('arko-dialog-content')
        d.format_secondary_text(str(text));d.set_default_size(500,-1)
        if question:d.set_default_response(Gtk.ResponseType.NO)
        return d
    def message(self,title,text):
        d=self.message_dialog(title,text);d.run();d.destroy()
    def confirm(self,title,text):
        d=self.message_dialog(title,text,True);result=d.run()==Gtk.ResponseType.YES;d.destroy();return result
    def pair_prompt(self,e):
        ok=self.confirm('Appairage avec '+e.get('name',''),f"Code : {e.get('code','')}\n\nVérifie que le même code apparaît sur l’autre ordinateur. Confirmer ?")
        self.send('pair_decision',request_id=e['request_id'],accept=ok)
    def apply_license(self,status):
        self.license=status;self.license_label.set_text('Licence : '+status.get('message',status.get('state','inconnue')))
    def license_dialog(self):
        d=Gtk.Dialog(title='Licence Arko Bridge',transient_for=self.window,modal=True);d.add_button('Fermer',Gtk.ResponseType.CLOSE);b=d.get_content_area();b.set_spacing(12);b.set_border_width(20)
        b.add(label(self.license.get('message','')));entry=Gtk.Entry(visibility=False);entry.set_placeholder_text('Clé de licence');b.add(entry)
        def activate():
            key=entry.get_text().strip();entry.set_text('')
            if key:self.send('license_activate',license_key=key);d.response(Gtk.ResponseType.CLOSE)
        b.add(row(button('Activer',activate,True),button('Revérifier',lambda:self.send('license_refresh'))))
        def trial():
            if self.confirm('Démarrer l’essai de 14 jours ?','La demande sera envoyée au service de licence.'):self.send('license_trial');d.response(Gtk.ResponseType.CLOSE)
        b.add(button('Essayer pendant 14 jours',trial));d.show_all();d.run();entry.set_text('');d.destroy()
    def show_transfer(self,e):
        total=e.get('bytes_total',0);done=e.get('bytes_done',0);status=e.get('status','');tid=e.get('id','')
        key=(tid,e.get('peer_id',''),e.get('direction',''))
        if tid and key in self.transfer_finished:return
        self.transfer_serial+=1;serial=self.transfer_serial
        terminal=status in ('completed','cancelled','failed')
        if terminal:
            if tid:self.transfer_finished=(self.transfer_finished+[key])[-128:]
            GLib.timeout_add(6000 if status=='failed' else 4000,self.hide_transfer,serial)
        self.transfer_panel.show();self.transfer_text.show();self.transfer.show();self.transfer.set_fraction(max(0,min(1,done/total)) if total else 0)
        self.transfer_text.set_text(f"Transfert presse-papiers : {size(done)} / {size(total)} · {size(e.get('bytes_per_second'))}/s · {e.get('message',status)}")
        self.transfer_text.set_tooltip_text(self.transfer_text.get_text())
        if status=='confirmation_required' and tid not in self.pending_confirm:
            self.pending_confirm.add(tid)
            ok=self.confirm('Transférer '+size(total)+' ?',e.get('message','La limite choisie dans les réglages est dépassée.'))
            self.send('clipboard_confirm' if ok else 'clipboard_cancel',transfer_id=tid)
    def hide_transfer(self,serial):
        # An older completion must not hide a new transfer (or restart its bar).
        if serial==self.transfer_serial:self.transfer_panel.hide()
        return False
    def show_history(self,entries):
        d=Gtk.Dialog(title='Historique chiffré',transient_for=self.window);d.add_button('Fermer',Gtk.ResponseType.CLOSE);d.set_default_size(600,400);b=box()
        for e in entries:
            w=button(e.get('preview',e.get('kind',''))[:160],lambda e=e:(self.send('clipboard_history_select',history_id=e['id']),d.response(Gtk.ResponseType.CLOSE)));w.set_sensitive(e.get('available',True));b.add(w)
        if not entries:b.add(label('Aucune copie conservée.'))
        d.get_content_area().pack_start(scroll(b),True,True,0);d.show_all();d.run();d.destroy()
    def clear_history(self):
        if self.confirm('Effacer l’historique ?','Les fichiers d’origine restent conservés.'):self.send('clipboard_history_clear')
    def open_cache(self):
        path=DATA/'Clipboard Transfers';path.mkdir(exist_ok=True);Gio.AppInfo.launch_default_for_uri(path.as_uri(),None)
    def open_keyboard(self):
        try:subprocess.Popen(['gnome-control-center','keyboard'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
        except OSError:self.message('Clavier','Ouvre les réglages du clavier dans les préférences de ton bureau Linux.')
    def send_files(self):
        if not self.selected:return self.message('Fichiers','Sélectionne d’abord un ordinateur.')
        d=Gtk.FileChooserDialog(title='Envoyer des fichiers',transient_for=self.window,action=Gtk.FileChooserAction.OPEN);d.add_buttons('Annuler',Gtk.ResponseType.CANCEL,'Envoyer',Gtk.ResponseType.OK);d.set_select_multiple(True)
        if d.run()==Gtk.ResponseType.OK:self.send('files_send',peer_id=self.selected,paths=d.get_filenames())
        d.destroy()
    def updates(self):
        from updates import run
        try:version=(ROOT/'VERSION').read_text().strip()
        except OSError as error:self.message('Mises à jour',str(error));return
        self.update_button.set_sensitive(False);self.update_button.set_label('Vérification…')
        run(ROOT,DATA,'check',['--data-dir',str(DATA),'--current-version',version],self.update_checked)
    def update_checked(self,payload):
        if self.closing:return False
        self.update_button.set_sensitive(True);self.update_button.set_label('Rechercher une mise à jour')
        if not payload.get('ok'):self.message('Mises à jour',payload.get('error','Vérification impossible'));return False
        result=payload.get('result',{})
        if not result.get('available'):self.message('Mises à jour','Cette version est à jour.');return False
        if not self.confirm('Version '+result.get('version','')+' disponible',result.get('notes','')+'\n\nTélécharger et installer ? Bridge redémarrera et conservera tes réglages.'):return False
        import tempfile
        DATA.mkdir(parents=True,exist_ok=True)
        destination=str(pathlib.Path(tempfile.mkdtemp(prefix='update-',dir=str(DATA)))/'package.tar.gz')
        from updates import run
        self.update_button.set_sensitive(False);self.update_button.set_label('Téléchargement…')
        run(ROOT,DATA,'download',['--manifest-url',result['manifest_url'],'--destination',destination],self.update_downloaded)
        return False
    def update_downloaded(self,payload):
        if self.closing:return False
        self.update_button.set_sensitive(True);self.update_button.set_label('Rechercher une mise à jour')
        if payload.get('ok'):
            from updates import install
            self.update_button.set_sensitive(False);self.update_button.set_label('Préparation…')
            install(ROOT,DATA,payload['path'],self.update_prepared)
        else:self.message('Téléchargement refusé',payload.get('error','Vérification impossible'))
        return False
    def update_prepared(self,payload):
        if self.closing:return False
        if payload.get('ok') and payload.get('prepared'):self.quit()
        else:
            self.update_button.set_sensitive(True);self.update_button.set_label('Rechercher une mise à jour')
            self.message('Installation annulée',payload.get('error','Préparation impossible'))
        return False
    def update_ready(self):
        target=os.environ.pop('ARKO_UPDATE_READY_FILE',None)
        if target:
            try:
                version=(ROOT/'VERSION').read_text().strip()
                path=pathlib.Path(target);tmp=path.with_suffix('.tmp')
                tmp.write_text(version);tmp.chmod(0o600);tmp.replace(path)
            except OSError as error:self.log('WARNING','Confirmation de redémarrage impossible : '+str(error))
    def log(self,level,message):
        line=f'[{datetime.datetime.now():%H:%M:%S}] [{level}] {message}';self.logs.append(line);self.logs=self.logs[-1000:]
        self.log_view.get_buffer().set_text('\n'.join(self.logs));return False
    def copy_logs(self):Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD).set_text('\n'.join(self.logs),-1)
    def clear_logs(self):self.logs=[];self.log_view.get_buffer().set_text('')
    def export_logs(self):
        d=Gtk.FileChooserDialog(title='Exporter le diagnostic',transient_for=self.window,action=Gtk.FileChooserAction.SAVE);d.add_buttons('Annuler',Gtk.ResponseType.CANCEL,'Exporter',Gtk.ResponseType.OK);d.set_do_overwrite_confirmation(True);d.set_current_name('Arko-diagnostic.txt')
        if d.run()==Gtk.ResponseType.OK:
            path=pathlib.Path(d.get_filename());path.write_text('\n'.join(self.logs));path.chmod(0o600)
        d.destroy()
    def do_shutdown(self):
        self.closing=True
        if hasattr(self,"app_rules"):self.app_rules.stop()
        if self.history_timer:GLib.source_remove(self.history_timer)
        if self.crossing_timer:GLib.source_remove(self.crossing_timer)
        if self.history_poll:GLib.source_remove(self.history_poll)
        if self.history_grab:self.history_grab.close()
        if self.sharing_poll:GLib.source_remove(self.sharing_poll)
        if self.sharing_grab:self.sharing_grab.close()
        if self.power_cookie:self.uninhibit(self.power_cookie)
        if self.proc and self.proc.poll() is None:
            self.send('shutdown')
            try:self.proc.wait(timeout=3)
            except subprocess.TimeoutExpired:
                self.proc.terminate()
                try:self.proc.wait(timeout=3)
                except subprocess.TimeoutExpired:self.proc.kill();self.proc.wait(timeout=3)
        Gtk.Application.do_shutdown(self)

if __name__=='__main__':
    os.umask(0o077);sys.exit(Bridge().run(sys.argv))
